feat: unified reference numbers + feedback modules QA audit
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m14s
Reference numbers (unified scheme PREFIX-YYYYMM-HOSP-NNNN, e.g. CMP-202606-HHN-0001): - new ReferenceSequence model + generate_reference() helper (apps/core) - Complaint/Inquiry/Observation/Appreciation/Suggestion emit unified refs via save() - prefix-based auto-routing in public track API (CMP/INQ/OBS trackable; APR/SGT internal-only) - removed legacy CMP-/INQ- generators in ui_views, integrations, px_sources - migrations: core.0003_referencesequence, appreciation.0006, feedback.0008, observations.0012 - unit tests (format, sanitization, monthly reset, 40-thread concurrency) QA audit: - isolated E2E hospital sandbox mirroring HH-N + 10 role users (create_e2e_isolated_env) - feedback-modules-audit.spec.ts + audit helper (headed, run-to-completion) - reports/feedback-modules-qa-report.md Also bundles accumulated in-progress work across complaints, observations, organizations, templates, and other modules.
This commit is contained in:
parent
7dae32d206
commit
7369d08012
102
.mimocode/plans/1781172520267-calm-canyon.md
Normal file
102
.mimocode/plans/1781172520267-calm-canyon.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# Plan: Unify Department Response + Add Reporter Notifications
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
1. Make all three types use the same department response approach (token-based, no login)
|
||||||
|
2. Add reporter notifications for complaint/inquiry when department responds
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. UNIFY DEPARTMENT RESPONSE (Token-Based for All)
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- **Complaint**: Uses `ComplaintExplanation` model with token links — champion clicks link, submits explanation without login
|
||||||
|
- **Observation**: Champion logs into system, navigates to department detail page, submits response via form
|
||||||
|
- **Inquiry**: Same as observation — login required
|
||||||
|
|
||||||
|
### Target State
|
||||||
|
All three use token-based links (like complaint):
|
||||||
|
- PX team sends to department → champion receives email with one-time link
|
||||||
|
- Champion clicks link → submits response without login
|
||||||
|
- Link expires after use
|
||||||
|
|
||||||
|
### Changes Needed
|
||||||
|
|
||||||
|
#### a) Create shared `DepartmentResponse` model (or reuse existing patterns)
|
||||||
|
Better approach: Create a token-based response flow for observation and inquiry similar to complaint's `ComplaintExplanation`.
|
||||||
|
|
||||||
|
**New model in `apps/organizations/models.py`** (or `apps/core/models.py`):
|
||||||
|
```python
|
||||||
|
class DepartmentResponseToken(UUIDModel, TimeStampedModel):
|
||||||
|
"""Token-based department response link for observations and inquiries."""
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.UUIDField()
|
||||||
|
content_object = GenericForeignKey('content_type', 'object_id')
|
||||||
|
|
||||||
|
department = models.ForeignKey('organizations.Department', on_delete=models.CASCADE)
|
||||||
|
staff = models.ForeignKey('organizations.Staff', on_delete=models.CASCADE)
|
||||||
|
token = models.CharField(max_length=100, unique=True)
|
||||||
|
|
||||||
|
is_used = models.BooleanField(default=False)
|
||||||
|
responded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# SLA
|
||||||
|
sla_due_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_overdue = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [models.Index(fields=['token'])]
|
||||||
|
```
|
||||||
|
|
||||||
|
Actually, simpler approach: just add token fields to the existing models and create views for token-based response.
|
||||||
|
|
||||||
|
**For Observation** (`apps/observations/models.py`):
|
||||||
|
- Add `response_token` field (CharField, unique, nullable)
|
||||||
|
- Add `response_token_used` (BooleanField, default=False)
|
||||||
|
- Add `response_token_sent_at` (DateTimeField, nullable)
|
||||||
|
|
||||||
|
**For Inquiry** (`apps/complaints/models.py`):
|
||||||
|
- Add same fields to Inquiry model
|
||||||
|
|
||||||
|
#### b) Create token-based response views
|
||||||
|
- `observation_respond_with_token(request, pk, token)` — public view, no auth required
|
||||||
|
- `inquiry_respond_with_token(request, pk, token)` — public view, no auth required
|
||||||
|
|
||||||
|
#### c) Update "Send to Department" views
|
||||||
|
- Generate token, include in email link
|
||||||
|
- Email template similar to complaint's explanation request
|
||||||
|
|
||||||
|
#### d) Update department detail page
|
||||||
|
- Remove direct response forms (or keep as fallback for logged-in users)
|
||||||
|
- Show token-based response status instead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ADD REPORTER NOTIFICATIONS
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- **Complaint**: No notification to complainant when department responds
|
||||||
|
- **Observation**: SMS + email to reporter when department responds ✓
|
||||||
|
- **Inquiry**: No notification to inquirer when department responds
|
||||||
|
|
||||||
|
### Changes Needed
|
||||||
|
|
||||||
|
#### a) Complaint — notify complainant on department response
|
||||||
|
**File**: `apps/complaints/ui_views.py` (in `involved_department_response` view)
|
||||||
|
- After department submits response, send SMS + email to complainant
|
||||||
|
- Include tracking link
|
||||||
|
|
||||||
|
#### b) Inquiry — notify inquirer on department response
|
||||||
|
**File**: `apps/complaints/ui_views.py` (in `inquiry_department_response` view)
|
||||||
|
- After department submits response, send SMS + email to inquirer
|
||||||
|
- Include tracking link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Add token fields to Observation and Inquiry models
|
||||||
|
2. Create token-based response views for both
|
||||||
|
3. Update "Send to Department" to generate tokens and send email links
|
||||||
|
4. Add reporter notifications to complaint and inquiry department response flows
|
||||||
|
5. Update templates
|
||||||
|
6. Create migration
|
||||||
115
.opencode/plans/fix-complaint-reimport.md
Normal file
115
.opencode/plans/fix-complaint-reimport.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Fix: Re-import Complaints with Missing Data
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Full re-import of all complaints (2022-2025) to fix missing timeline dates, satisfaction, resolution_outcome, and other data.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Delete all existing complaints
|
||||||
|
```python
|
||||||
|
Complaint.objects.all().delete()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fix `import_historical_complaints.py` (2022-2024)
|
||||||
|
|
||||||
|
**2a. Fix COLUMN_MAPPING — add missing columns:**
|
||||||
|
```python
|
||||||
|
COLUMN_MAPPING = {
|
||||||
|
# ... existing mappings ...
|
||||||
|
"form_sent_date": 12, # إرسال نموذج الشكوى → form_sent_at
|
||||||
|
"activated_date": 17, # تفعيل الشكوى → activated_at
|
||||||
|
# col 20 (date_sent) stays → forwarded_to_dept_at (was wrongly mapped to activated_at)
|
||||||
|
"escalation_reason": 35, # Reason of Escalation → metadata
|
||||||
|
"recommendation": 58, # Recommendation/Action plan → recommendation_action_plan
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2b. Fix Complaint.objects.create() — add fields:**
|
||||||
|
- `form_sent_at=form_sent_date` (col 12)
|
||||||
|
- `activated_at=activated_date` (col 17, NOT col 20)
|
||||||
|
- `forwarded_to_dept_at=date_sent` (col 20, was wrongly in activated_at)
|
||||||
|
- `satisfaction=normalize_satisfaction(satisfaction_val)` (col 56, write to model field NOT just metadata)
|
||||||
|
- `recommendation_action_plan=recommendation` (col 58)
|
||||||
|
|
||||||
|
**2c. Add satisfaction normalization helper:**
|
||||||
|
```python
|
||||||
|
def _normalize_satisfaction(self, val):
|
||||||
|
val = str(val or "").strip().lower()
|
||||||
|
mapping = {"satisfied": "satisfied", "dissatisfied": "dissatisfied", "no response": "no_response"}
|
||||||
|
return mapping.get(val, "")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Fix `import_2025_complaints_basic.py`
|
||||||
|
|
||||||
|
**3a. Fix HEADER_ALIASES — fix satisfaction and add missing columns:**
|
||||||
|
```python
|
||||||
|
HEADER_ALIASES = {
|
||||||
|
# ... existing correct mappings ...
|
||||||
|
"satisfaction": ["Satisfied/Dissatisfied"], # FIXED: was pointing to wrong column
|
||||||
|
"rightful_side": ["The Rightful Side"], # NEW: col 66
|
||||||
|
"complaint_subject": ["موضوع الشكوى الأساسية"], # NEW: col 53
|
||||||
|
"form_sent_date": ["إرسال نموذج الشكوى"], # NEW: col 16
|
||||||
|
"activated_date": ["تفعيل الشكوى"], # NEW: col 21
|
||||||
|
"sent_date": ["تم ارسال الشكوى"], # NEW: col 24
|
||||||
|
"first_reminder": ["First Reminder Sent"], # NEW: col 28
|
||||||
|
"second_reminder": ["Second Reminder Sent"], # NEW: col 32
|
||||||
|
"escalated_date": ["Escalated"], # NEW: col 36
|
||||||
|
"closed_date": ["Closed"], # NEW: col 40
|
||||||
|
"resolved_date": ["Resolved"], # NEW: col 44
|
||||||
|
"delay_reason": ["سبب تأخير القسم بالرد"], # NEW: col 58
|
||||||
|
"closure_delay": ["سبب تأخير اغلاق الشكوى خلال 72 ساعه"], # col 59
|
||||||
|
"action_taken": ["الاجراء المتخذ من قبل القسم المعني"], # col 62
|
||||||
|
"action_result": ["نتيجة الاجراء المتخذ بعد التحقيق"], # col 63
|
||||||
|
"recommendation": ["Recommendation/Action plan"], # col 64
|
||||||
|
"solutions": ["حلول واقتراحات"], # col 67
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3b. Fix _process_sheet() — parse new timeline dates:**
|
||||||
|
Add parsing for all new date fields (form_sent_date, activated_date, sent_date, first_reminder, second_reminder, escalated_date, closed_date, resolved_date).
|
||||||
|
|
||||||
|
**3c. Fix status determination:**
|
||||||
|
```python
|
||||||
|
# Current: only checks response_date → open/resolved
|
||||||
|
# Fixed: check all dates like historical script
|
||||||
|
if closed_date:
|
||||||
|
status = "closed"
|
||||||
|
elif resolved_date:
|
||||||
|
status = "resolved"
|
||||||
|
elif escalated_date:
|
||||||
|
status = "in_progress"
|
||||||
|
else:
|
||||||
|
status = "open"
|
||||||
|
```
|
||||||
|
|
||||||
|
**3d. Fix Complaint.objects.create() — add all missing fields:**
|
||||||
|
- `activated_at=activated_date`
|
||||||
|
- `form_sent_at=form_sent_date`
|
||||||
|
- `forwarded_to_dept_at=sent_date`
|
||||||
|
- `reminder_sent_at=first_reminder`
|
||||||
|
- `second_reminder_sent_at=second_reminder`
|
||||||
|
- `escalated_at=escalated_date`
|
||||||
|
- `closed_at=closed_date`
|
||||||
|
- `resolved_at=resolved_date`
|
||||||
|
- `satisfaction=normalize_satisfaction(satisfaction_val)`
|
||||||
|
- `resolution_outcome=normalize_rightful_side(rightful_side)`
|
||||||
|
- `complaint_subject=complaint_subject`
|
||||||
|
- `explanation_delay_reason=delay_reason`
|
||||||
|
- `delay_reason_closure=closure_delay`
|
||||||
|
- `action_taken_by_dept=action_taken`
|
||||||
|
- `action_result=action_result`
|
||||||
|
- `recommendation_action_plan=recommendation or solutions`
|
||||||
|
|
||||||
|
### 4. Run re-import
|
||||||
|
```bash
|
||||||
|
python manage.py shell -c "from apps.complaints.models import Complaint; Complaint.objects.all().delete()"
|
||||||
|
python manage.py import_all_complaints --hospital-code=HH-N
|
||||||
|
python manage.py backfill_sent_to_department
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify
|
||||||
|
- Check complaint counts match
|
||||||
|
- Check timeline dates populated
|
||||||
|
- Check satisfaction field populated
|
||||||
|
- Check resolution_outcome populated
|
||||||
|
- Check department detail page shows correct data
|
||||||
41
.opencode/plans/fix-department-detail-complaints.md
Normal file
41
.opencode/plans/fix-department-detail-complaints.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Fix: Department Detail Shows No Complaints + Template Field References
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
All 2163 complaints have `sent_to_department=False`. The department detail view queries filter by `sent_to_department=True`, so zero complaints/observations/inquiries appear. The import scripts (`import_all_complaints.py`, `import_historical_complaints.py`) explicitly set `sent_to_department=False`, overwriting the backfill migration from May 16.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. NEW FILE: `apps/complaints/management/commands/backfill_sent_to_department.py`
|
||||||
|
Create management command that sets `sent_to_department=True` on:
|
||||||
|
- `Complaint.objects.filter(department__isnull=False, sent_to_department=False).update(sent_to_department=True, sent_to_department_at=F('created_at'))`
|
||||||
|
- `Observation.objects.filter(assigned_department__isnull=False, sent_to_department=False).update(sent_to_department=True, sent_to_department_at=F('created_at'))`
|
||||||
|
- `Inquiry.objects.filter(department__isnull=False, sent_to_department=False).update(sent_to_department=True, sent_to_department_at=F('created_at'))`
|
||||||
|
- `ComplaintInvolvedDepartment.objects.filter(sent=False, complaint__department__isnull=False).update(sent=True)`
|
||||||
|
|
||||||
|
### 2. EDIT: `apps/complaints/management/commands/import_all_complaints.py`
|
||||||
|
- Change `sent_to_department=False` → `sent_to_department=True`
|
||||||
|
|
||||||
|
### 3. EDIT: `apps/complaints/management/commands/import_historical_complaints.py`
|
||||||
|
- Change `sent_to_department=False` → `sent_to_department=True`
|
||||||
|
|
||||||
|
### 4. EDIT: `templates/organizations/orgsection_list.html`
|
||||||
|
- Remove `point_of_contact` column (Section model no longer has this field)
|
||||||
|
|
||||||
|
### 5. EDIT: `templates/organizations/orgsection_form.html`
|
||||||
|
- Remove `point_of_contact` form field (lines 136-140)
|
||||||
|
|
||||||
|
### 6. EDIT: `templates/complaints/government_ticket_form.html`
|
||||||
|
- Line 295: change `sec.name` → `sec.name_en || sec.name` (API returns `name_en`, not `name`)
|
||||||
|
|
||||||
|
### 7. EDIT: `templates/px_sources/source_user_create_complaint.html`
|
||||||
|
- Line 303: change `sec.name` → `sec.name_en || sec.name`
|
||||||
|
|
||||||
|
### 8. RUN: `python manage.py backfill_sent_to_department`
|
||||||
|
- Expected: 2163 complaints, 3 observations, 2 inquiries updated
|
||||||
|
|
||||||
|
### 9. VERIFY: `python manage.py check` + tests
|
||||||
|
|
||||||
|
## What was ALREADY fixed in previous session
|
||||||
|
- `department_detail.html`: `sub_subsections` → `subsections`, removed floor/poc columns, added champion/supervisor/deputy columns
|
||||||
|
- `orgsection_detail.html`: removed `point_of_contact` from header
|
||||||
|
- `ui_views.py:2173`: added supervisor/deputy_supervisor to select_related
|
||||||
493
DEPARTMENT_HIERARCHY_HANDOFF.md
Normal file
493
DEPARTMENT_HIERARCHY_HANDOFF.md
Normal file
@ -0,0 +1,493 @@
|
|||||||
|
# Department & Complaint Hierarchy Migration - Full Technical Handoff
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
PX360 is a hospital patient experience management system (Django + SQLite). We are migrating from a **3-level legacy hierarchy** (Location → MainSection → SubSection) to a **new hierarchy** (Department → Section → Sub-Section) with proper org structure, role-based contact persons, and department-centric complaint routing.
|
||||||
|
|
||||||
|
The legacy models are **not deleted** — they are renamed with `Legacy*` prefix and `db_table` preserved. The new `Department` model has 7 role-holder FKs (all FK to `Staff`), and complaints/inquiries/observations can be routed to departments with mandatory champion or manager validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|---|---|
|
||||||
|
| Total complaints | 2,160 |
|
||||||
|
| Complaints with `department_id` set | 2,151 (99.6%) |
|
||||||
|
| Complaints with `legacy_subsection_id` | 2,150 |
|
||||||
|
| Complaints without any department | 9 |
|
||||||
|
| Total departments (new) | 66 |
|
||||||
|
| Total sections (OrgSubSection) | 152 |
|
||||||
|
| Total sub-sections (OrgSubSubSection) | 17 |
|
||||||
|
| LegacyHierarchyMapping rows | 88 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### New Models (created via migrations 0001-0006)
|
||||||
|
|
||||||
|
#### `organizations_department` (UUID PK, 30 columns)
|
||||||
|
```python
|
||||||
|
class Department(models.Model):
|
||||||
|
# Location fields
|
||||||
|
hospital = FK(Hospital, CASCADE, related_name="departments")
|
||||||
|
area = CharField(100) # High-level area
|
||||||
|
main_section = CharField(200) # Main section name
|
||||||
|
name = CharField(200)
|
||||||
|
name_en = CharField(200, blank=True)
|
||||||
|
name_ar = CharField(200, blank=True)
|
||||||
|
code = CharField(100, db_index=True)
|
||||||
|
category = CharField(30, choices=DepartmentCategory, db_index=True)
|
||||||
|
parent = FK(self, SET_NULL, null=True)
|
||||||
|
location_type = CharField(20) # OP, IP, ER, GENERAL
|
||||||
|
sub_location = CharField(200)
|
||||||
|
floor = CharField(50)
|
||||||
|
|
||||||
|
# Role holders (all FK to Staff)
|
||||||
|
manager = FK(accounts.User, SET_NULL, null=True)
|
||||||
|
champion = FK(Staff, SET_NULL, null=True, related_name="champion_departments")
|
||||||
|
manager_1st = FK(Staff, SET_NULL, null=True, related_name="dept_manager_1st")
|
||||||
|
manager_2nd = FK(Staff, SET_NULL, null=True, related_name="dept_manager_2nd")
|
||||||
|
manager_3rd = FK(Staff, SET_NULL, null=True, related_name="dept_manager_3rd")
|
||||||
|
deputy_manager = FK(Staff, SET_NULL, null=True, related_name="dept_deputy_manager")
|
||||||
|
supervisor = FK(Staff, SET_NULL, null=True, related_name="dept_supervisor")
|
||||||
|
deputy_supervisor = FK(Staff, SET_NULL, null=True, related_name="dept_deputy_supervisor")
|
||||||
|
|
||||||
|
# Contact / legacy
|
||||||
|
champion_email = EmailField(200, blank=True)
|
||||||
|
old_name_en = CharField(200, blank=True) # Legacy English name from Excel
|
||||||
|
old_name_ar = CharField(200, blank=True) # Legacy Arabic name from Excel
|
||||||
|
phone = CharField(20, blank=True)
|
||||||
|
email = EmailField(blank=True)
|
||||||
|
location = CharField(200, blank=True) # Free text (NOT a FK)
|
||||||
|
status = CharField(20, default="active")
|
||||||
|
|
||||||
|
ROLE_FIELDS = [
|
||||||
|
("champion", "Champion"),
|
||||||
|
("manager_1st", "1st Manager"),
|
||||||
|
("manager_2nd", "2nd Manager"),
|
||||||
|
("manager_3rd", "3rd Manager"),
|
||||||
|
("deputy_manager", "Deputy Manager"),
|
||||||
|
("supervisor", "Supervisor"),
|
||||||
|
("deputy_supervisor", "Deputy Supervisor"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_role_holders(self):
|
||||||
|
"""Returns list of dicts: {staff, staff_id, name, email, role_field, role_label}"""
|
||||||
|
|
||||||
|
def is_valid_contact_person(self, staff_id):
|
||||||
|
"""Validates a staff_id against all 7 role FKs. Returns holder dict or None."""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `organizations_orgsubsection` (UUID PK, 152 rows)
|
||||||
|
```python
|
||||||
|
class OrgSubSection(models.Model):
|
||||||
|
department = FK(Department, CASCADE, related_name="org_subsections")
|
||||||
|
name_en = CharField(200)
|
||||||
|
name_ar = CharField(200, blank=True)
|
||||||
|
code = CharField(100, blank=True)
|
||||||
|
location_type = CharField(20)
|
||||||
|
sub_location = CharField(200)
|
||||||
|
floor = CharField(50)
|
||||||
|
point_of_contact = FK(Staff, SET_NULL, null=True)
|
||||||
|
old_name_en = CharField(200, blank=True)
|
||||||
|
old_name_ar = CharField(200, blank=True)
|
||||||
|
status = CharField(20, default="active")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("department", "code")]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `organizations_orgsubsubsection` (UUID PK, 17 rows)
|
||||||
|
```python
|
||||||
|
class OrgSubSubSection(models.Model):
|
||||||
|
subsection = FK(OrgSubSection, CASCADE, related_name="sub_subsections")
|
||||||
|
name_en = CharField(200)
|
||||||
|
name_ar = CharField(200, blank=True)
|
||||||
|
code = CharField(100, blank=True)
|
||||||
|
status = CharField(20, default="active")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("subsection", "code")]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `organizations_legacyhierarchymapping` (UUID PK, 88 rows)
|
||||||
|
```python
|
||||||
|
class LegacyHierarchyMapping(models.Model):
|
||||||
|
old_location_ar = CharField(200, db_index=True)
|
||||||
|
old_main_section_ar = CharField(200, db_index=True)
|
||||||
|
old_subsection_ar = CharField(200, db_index=True)
|
||||||
|
old_location_en = CharField(200, blank=True)
|
||||||
|
old_main_section_en = CharField(200, blank=True)
|
||||||
|
old_subsection_en = CharField(200, blank=True)
|
||||||
|
main_section = FK(Department, SET_NULL, null=True) # Maps to Department
|
||||||
|
subsection = FK(OrgSubSection, SET_NULL, null=True)
|
||||||
|
sub_subsection = FK(OrgSubSubSection, SET_NULL, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("old_location_ar", "old_main_section_ar", "old_subsection_ar")]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Legacy Models (renamed, db_table preserved, backward-compat aliases)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LegacyLocation(models.Model): # db_table = "organizations_location"
|
||||||
|
id = models.IntegerField(primary_key=True)
|
||||||
|
name_ar, name_en
|
||||||
|
ACTIVE_IDS = [48, 49, 82, 110]
|
||||||
|
|
||||||
|
class LegacyMainSection(models.Model): # db_table = "organizations_mainsection"
|
||||||
|
id = models.IntegerField(primary_key=True)
|
||||||
|
name_ar, name_en
|
||||||
|
|
||||||
|
class LegacySubSection(models.Model): # db_table = "organizations_subsection"
|
||||||
|
internal_id = models.IntegerField(primary_key=True)
|
||||||
|
name_ar, name_en
|
||||||
|
location = FK(LegacyLocation)
|
||||||
|
main_section = FK(LegacyMainSection)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complaint Model Hierarchy Fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Complaint(models.Model):
|
||||||
|
# NEW hierarchy
|
||||||
|
department = FK(Department, SET_NULL, null=True, related_name="complaints")
|
||||||
|
section = FK(OrgSubSection, PROTECT, null=True, related_name="complaints")
|
||||||
|
sub_subsection = FK(OrgSubSubSection, PROTECT, null=True, related_name="complaints")
|
||||||
|
|
||||||
|
# LEGACY FKs (kept for backward compatibility)
|
||||||
|
legacy_location = FK(LegacyLocation, PROTECT, null=True, related_name="complaints")
|
||||||
|
legacy_main_section = FK(LegacyMainSection, PROTECT, null=True, related_name="complaints")
|
||||||
|
legacy_subsection = FK(LegacySubSection, PROTECT, null=True, related_name="complaints")
|
||||||
|
|
||||||
|
# Raw text (for mapping/audit)
|
||||||
|
old_location_raw = CharField(200, blank=True, db_index=True)
|
||||||
|
old_main_section_raw = CharField(200, blank=True, db_index=True)
|
||||||
|
old_subsection_raw = CharField(200, blank=True, db_index=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Inquiry and Observation models have the same pattern of new + legacy + raw fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL Structure
|
||||||
|
|
||||||
|
### New hierarchy dropdown APIs (in `apps/organizations/urls.py`)
|
||||||
|
```
|
||||||
|
/dropdowns/departments-by-category/?category=<cat> → api_departments_by_category
|
||||||
|
/dropdowns/sections/<uuid:department_id>/ → api_sections_by_department
|
||||||
|
/dropdowns/sub-subsections/<uuid:section_id>/ → api_sub_subsections_by_section
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role management
|
||||||
|
```
|
||||||
|
/department-contacts/<uuid:dept_id>/ → api_department_contacts (GET, returns role holders JSON)
|
||||||
|
/set-role/ → set_department_role (POST, sets/clears a role on dept)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complaint send/escalate (AJAX endpoints)
|
||||||
|
```
|
||||||
|
/complaints/<uuid:pk>/send-to/ → complaint_send_to (POST)
|
||||||
|
/complaints/<uuid:pk>/escalate/ → complaint_escalate (POST)
|
||||||
|
/complaints/<uuid:pk>/send-to-department/ → complaint_send_to (POST, same endpoint)
|
||||||
|
/inquiries/<uuid:pk>/escalate/ → inquiry_escalate (POST)
|
||||||
|
/inquiries/<uuid:pk>/send-to/ → inquiry_send_to (POST)
|
||||||
|
/observations/<uuid:pk>/escalate/ → observation_escalate (POST)
|
||||||
|
/observations/<uuid:pk>/send-to/ → observation_send_to (POST)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Legacy dropdown APIs (still active, used by internal forms)
|
||||||
|
```
|
||||||
|
/dropdowns/locations/ → api_locations
|
||||||
|
/ajax/main-sections/?location=<id> → ajax_main_sections
|
||||||
|
/ajax/subsections/?location=<id>&main_section=<id> → ajax_subsections
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Code Files
|
||||||
|
|
||||||
|
### Models
|
||||||
|
| File | Content |
|
||||||
|
|---|---|
|
||||||
|
| `apps/organizations/models.py:151-279` | Department model (30 fields, ROLE_FIELDS, get_role_holders, is_valid_contact_person) |
|
||||||
|
| `apps/organizations/models.py:697-726` | OrgSubSection model |
|
||||||
|
| `apps/organizations/models.py:729-747` | OrgSubSubSection model |
|
||||||
|
| `apps/organizations/models.py:750-779` | LegacyHierarchyMapping model |
|
||||||
|
| `apps/organizations/models.py:651-694` | LegacyLocation, LegacyMainSection, LegacySubSection |
|
||||||
|
| `apps/complaints/models.py:260-378` | Complaint model (department, section, sub_subsection, legacy_*_id, old_*_raw) |
|
||||||
|
|
||||||
|
### Views (UI + API)
|
||||||
|
| File | Lines | Content |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/organizations/ui_views.py:1876-2187` | `department_detail` — tabbed view with roles, complaints, inquiries, etc. |
|
||||||
|
| `apps/organizations/ui_views.py:3503-3545` | `set_department_role` — POST to set/clear one of 7 role FKs |
|
||||||
|
| `apps/organizations/views.py:838-869` | 4 new dropdown/contacts API endpoints |
|
||||||
|
| `apps/complaints/ui_views.py:294-413` | `complaint_list` — filters by `department_id` (line 398) |
|
||||||
|
| `apps/complaints/ui_views.py:954-1140` | `complaint_send_to` — validates champion/manager, contact person |
|
||||||
|
| `apps/complaints/ui_views.py:1584-1682` | `complaint_escalate` — manual escalation to specific staff |
|
||||||
|
| `apps/complaints/ui_views.py:3128-3195` | `inquiry_escalate` |
|
||||||
|
| `apps/complaints/ui_views.py:3200-3348` | `inquiry_send_to` |
|
||||||
|
| `apps/complaints/ui_views.py:6243-6296` | `government_ticket_list` — filters by `legacy_main_section_id` |
|
||||||
|
| `apps/complaints/ui_views.py:6300-6315` | `government_ticket_detail` |
|
||||||
|
| `apps/complaints/ui_views.py:6628-6659` | `government_ticket_export` |
|
||||||
|
| `apps/observations/views.py:1160-1236` | `observation_escalate` |
|
||||||
|
| `apps/observations/views.py:1241-1401` | `observation_send_to` |
|
||||||
|
| `apps/observations/views.py:1032-1155` | `observation_send_to_department` |
|
||||||
|
|
||||||
|
### Services
|
||||||
|
| File | Content |
|
||||||
|
|---|---|
|
||||||
|
| `apps/complaints/services/complaint_service.py` | `ComplaintService.send_to_department` |
|
||||||
|
| `apps/complaints/tasks.py:672-679` | `escalate_complaint_auto` — DISABLED (no-op, returns immediately) |
|
||||||
|
| `apps/complaints/tasks.py:682-867` | `_escalate_complaint_auto_original` — preserved but not dispatched |
|
||||||
|
| `apps/complaints/tasks.py:870-958` | `escalate_after_reminder` — effectively disabled |
|
||||||
|
| `apps/complaints/tasks.py:358-390` | `check_overdue_complaints` — still runs, calls disabled auto-escalate |
|
||||||
|
| `apps/observations/services.py` | `ObservationService.create_observation` — accepts section, sub_subsection |
|
||||||
|
|
||||||
|
### Forms (using legacy hierarchy — NOT yet migrated)
|
||||||
|
| File | Form Class | Fields | Lines |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `apps/complaints/forms.py:58` | `PublicComplaintForm` | location, main_section, subsection (required=True) | 58-378 |
|
||||||
|
| `apps/complaints/forms.py:379` | `ComplaintForm` | location, main_section, subsection | 379-611 |
|
||||||
|
| `apps/complaints/forms.py:612` | `InquiryForm` | location, main_section, subsection (required=False) | 612-1220 |
|
||||||
|
| `apps/complaints/forms.py:1221` | `GovernmentTicketForm` | legacy_location, legacy_main_section, legacy_subsection | 1221-end |
|
||||||
|
|
||||||
|
All 4 forms use `LegacyLocation.active_locations()`, `LegacyMainSection`, `LegacySubSection` with server-side cascading in `__init__`.
|
||||||
|
|
||||||
|
### Templates — Migrated to New Hierarchy
|
||||||
|
| Template | What Changed |
|
||||||
|
|---|---|
|
||||||
|
| `templates/complaints/public_complaint_form.html` | Category → Department → Section → Sub-Section cascade (JS via new APIs) |
|
||||||
|
| `templates/core/public_submit.html` | 4 forms: complaint, observation, inquiry, appreciation — all switched to new hierarchy |
|
||||||
|
| `templates/components/send_to_modal.html` | Shared modal with contact person dropdown (calls `api_department_contacts`) |
|
||||||
|
| `templates/organizations/department_detail.html` | Roles tab with `#roleModal`, all 7 role fields |
|
||||||
|
|
||||||
|
### Templates — Still Using Old Hierarchy
|
||||||
|
| Template | Fields Displayed |
|
||||||
|
|---|---|
|
||||||
|
| `templates/complaints/complaint_form.html` | location, main_section, subsection (via ajax_* endpoints) |
|
||||||
|
| `templates/complaints/inquiry_form.html` | location, main_section, subsection |
|
||||||
|
| `templates/complaints/government_ticket_form.html` | location, main_section, subsection |
|
||||||
|
| `templates/complaints/government_ticket_list.html:166` | `ticket.legacy_main_section.name_en` |
|
||||||
|
| `templates/complaints/government_ticket_detail.html:128-136` | legacy_location, legacy_main_section, legacy_subsection |
|
||||||
|
| `templates/complaints/complaint_detail.html` | Displays legacy_* fields |
|
||||||
|
| `templates/complaints/complaint_pdf.html` | Displays legacy_* fields |
|
||||||
|
|
||||||
|
### Dashboard/Analytics Services — Still Using Old Hierarchy
|
||||||
|
| File | Lines | Issue |
|
||||||
|
|---|---|---|
|
||||||
|
| `apps/dashboard/services/complaint_monthly_service.py:54` | `select_related(..., "location", "main_section", ...)` | Uses `location` FK |
|
||||||
|
| `apps/dashboard/services/complaint_quarterly_service.py:238-255` | `_compute_location_breakdown()` | IP/OP/ER based on `location__name_en` string matching |
|
||||||
|
| `apps/dashboard/services/complaint_quarterly_service.py:593-626` | `get_chart_data()` | `c.location.name_en` |
|
||||||
|
| `apps/dashboard/services/complaint_monthly_export.py:204` | Export row | `c.location.name` |
|
||||||
|
| `apps/analytics/services/kpi_service.py:948-978` | `_create_location_breakdowns()` | Queries both `location` and `main_section` FKs |
|
||||||
|
| `apps/analytics/services/kpi_service.py:1540` | AI analysis | `select_related(..., "location", "main_section", ...)` |
|
||||||
|
| `apps/analytics/services/kpi_service.py:1621-1624` | Location counts | `c.location.name_en` |
|
||||||
|
|
||||||
|
### Management Commands
|
||||||
|
| File | Content |
|
||||||
|
|---|---|
|
||||||
|
| `apps/organizations/management/commands/import_departments_excel.py` (286 lines) | Excel import: matches by `hospital+name_en`, creates Dept/OrgSubSection/OrgSubSubSection/LegacyHierarchyMapping. Dry-run support. |
|
||||||
|
| `apps/organizations/management/commands/backfill_department_hierarchy.py` (284 lines) | 5-level cascade matching: manual override → LegacyHierarchyMapping → exact name → normalized → contains. 136-entry MANUAL_MAP dict. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Been Completed
|
||||||
|
|
||||||
|
1. **Schema reconciliation**: DB tables/columns created via raw SQL (all old Django migration records deleted, all new migration files faked-applied). 3 new tables, 100+ new columns.
|
||||||
|
|
||||||
|
2. **Legacy FK backfill on complaints**: `legacy_location_id`, `legacy_main_section_id`, `legacy_subsection_id` populated from old `location_id`, `main_section_id`, `subsection_id` for all 2,160 complaints. Raw text values (`old_*_raw`) also populated.
|
||||||
|
|
||||||
|
3. **Excel import**: 66 departments, 152 sections, 17 sub-sections, 88 legacy hierarchy mappings created.
|
||||||
|
|
||||||
|
4. **Backfill command**: 2,151/2,160 complaints now have `department_id` set (99.6%).
|
||||||
|
|
||||||
|
5. **Patient-facing complaint form**: Switched from Location→MainSection→SubSection to Category→Department→Section→SubSection.
|
||||||
|
|
||||||
|
6. **Public submit forms (4 forms)**: All switched to new hierarchy cascade.
|
||||||
|
|
||||||
|
7. **Observation service**: Updated to accept section/sub_subsection params.
|
||||||
|
|
||||||
|
8. **Send-to-department**: Validates champion or manager_1st exists, validates contact person against 7 role FKs.
|
||||||
|
|
||||||
|
9. **Contact person selection**: When sending to department, user picks from role holders. Champion pre-selected if available.
|
||||||
|
|
||||||
|
10. **Department detail**: Roles tab with single reusable modal for all 7 roles.
|
||||||
|
|
||||||
|
11. **Manual escalation**: Implemented for complaint, inquiry, and observation. Auto-escalation disabled (code preserved).
|
||||||
|
|
||||||
|
12. **Dropdown APIs**: 3 new endpoints for department→section→subsection cascade, contacts API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Remains (Not Yet Migrated)
|
||||||
|
|
||||||
|
### Critical
|
||||||
|
1. **Government ticket list view** (`ui_views.py:6265`): Filters by `legacy_main_section_id`. Should filter by `department`.
|
||||||
|
2. **Dashboard quarterly IP/OP/ER** (`complaint_quarterly_service.py:238-255`): Uses `location__name_en` string matching. Should derive from `department.location_type`.
|
||||||
|
3. **Dashboard monthly export** (`complaint_monthly_export.py:204`): Uses `c.location.name`. Should use department.
|
||||||
|
4. **KPI service** (`kpi_service.py:948-978`): Queries both `location` and `main_section` FKs.
|
||||||
|
|
||||||
|
### Medium
|
||||||
|
5. **Internal staff forms**: `complaint_form.html`, `inquiry_form.html`, `government_ticket_form.html` — still use old Location→MainSection→SubSection cascade with `ajax_main_sections`/`ajax_subsections` endpoints.
|
||||||
|
6. **31 departments without category**: Need category values assigned from the Excel data.
|
||||||
|
|
||||||
|
### Low
|
||||||
|
7. **Detail templates**: `complaint_detail.html`, `complaint_pdf.html`, `inquiry_detail.html`, `government_ticket_detail.html` — still display `legacy_*` fields.
|
||||||
|
8. **Stop writing legacy FKs**: New complaints could stop writing `legacy_*` FKs (or auto-populate from `department`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
- **Contact person = one of 7 department role holders** (champion, manager_1st-3rd, deputy_manager, supervisor, deputy_supervisor). Champion pre-selected if available.
|
||||||
|
- **Department cannot receive complaint without champion OR manager_1st**.
|
||||||
|
- **Auto-escalation disabled** but code preserved for future use.
|
||||||
|
- **Legacy models kept** with backward-compat aliases and preserved `db_table`.
|
||||||
|
- **Old API endpoints kept functional** for internal staff forms (gradual migration).
|
||||||
|
- **Import matches by `hospital+name_en`** to preserve existing Department IDs (critical for complaint FK integrity).
|
||||||
|
- **Hardcoded `MANUAL_MAP`** dict maps 136 legacy subsection PKs to department name strings for unmappable cases.
|
||||||
|
- **DB schema applied via raw SQL**, not Django migrations (production DB reconciliation).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints (New Hierarchy)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /organizations/dropdowns/departments-by-category/?category=medical
|
||||||
|
→ Returns: [{"id": "uuid", "name_en": "Internal Medicine", ...}, ...]
|
||||||
|
|
||||||
|
GET /organizations/dropdowns/sections/<uuid:department_id>/
|
||||||
|
→ Returns: [{"id": "uuid", "name_en": "Cardiology", ...}, ...]
|
||||||
|
|
||||||
|
GET /organizations/dropdowns/sub-subsections/<uuid:section_id>/
|
||||||
|
→ Returns: [{"id": "uuid", "name_en": "ECG Lab", ...}, ...]
|
||||||
|
|
||||||
|
GET /organizations/department-contacts/<uuid:dept_id>/
|
||||||
|
→ Returns: [{"staff_id": "uuid", "name": "Dr. X", "email": "...", "role_field": "champion", "role_label": "Champion"}, ...]
|
||||||
|
|
||||||
|
POST /organizations/set-role/
|
||||||
|
Body: {department_id, role_field, staff_id (optional)}
|
||||||
|
→ Sets/clears a role FK on Department
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
```
|
||||||
|
organizations
|
||||||
|
[X] 0001_initial
|
||||||
|
[X] 0002_rename_respondent_to_champion
|
||||||
|
[X] 0003_alter_department_champion
|
||||||
|
[X] 0004_legacylocation_legacymainsection_and_more
|
||||||
|
[X] 0005_alter_legacylocation_table_and_more
|
||||||
|
[X] 0006_alter_department_code_alter_orgsubsection_code_and_more
|
||||||
|
```
|
||||||
|
|
||||||
|
All migrations faked. DB schema in sync. Run with `--skip-checks` flag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 136-Entry Manual Mapping (MANUAL_MAP in backfill command)
|
||||||
|
|
||||||
|
The backfill command contains a hardcoded dict mapping 136 `LegacySubSection.internal_id` values to `Department.name_en` strings. These are legacy subsections that could not be automatically mapped via `LegacyHierarchyMapping` or name matching. Key distributions:
|
||||||
|
|
||||||
|
- Internal Medicine: ~14 entries
|
||||||
|
- Surgeries: ~11 entries
|
||||||
|
- Outpatient Department: ~10 entries
|
||||||
|
- Nursing Department: ~16 entries
|
||||||
|
- Emergency Department: ~6 entries
|
||||||
|
- Inpatient Department: ~7 entries
|
||||||
|
- Medical Ancillary Services: ~10 entries
|
||||||
|
- Others (Pediatric, Critical Care, OB/GYN, Pharmacy, IT, Security, Finance, etc.): 1-3 each
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Tree Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
├── organizations/
|
||||||
|
│ ├── models.py # Department, OrgSubSection, OrgSubSubSection, LegacyHierarchyMapping, Legacy*
|
||||||
|
│ ├── views.py # 4 new dropdown/contacts APIs + legacy ajax_* endpoints
|
||||||
|
│ ├── ui_views.py # department_detail, set_department_role
|
||||||
|
│ ├── urls.py # All URL patterns
|
||||||
|
│ ├── serializers.py # DepartmentSerializer, OrgSubSectionSerializer, OrgSubSubSectionSerializer
|
||||||
|
│ ├── management/commands/
|
||||||
|
│ │ ├── import_departments_excel.py # Excel import (286 lines)
|
||||||
|
│ │ └── backfill_department_hierarchy.py # Backfill with MANUAL_MAP (284 lines)
|
||||||
|
│ └── migrations/
|
||||||
|
│ ├── 0001_initial.py
|
||||||
|
│ ├── 0002_rename_respondent_to_champion.py
|
||||||
|
│ ├── 0003_alter_department_champion.py
|
||||||
|
│ ├── 0004_legacylocation_legacymainsection_and_more.py
|
||||||
|
│ ├── 0005_alter_legacylocation_table_and_more.py
|
||||||
|
│ └── 0006_alter_department_code_alter_orgsubsection_code_and_more.py
|
||||||
|
├── complaints/
|
||||||
|
│ ├── models.py # Complaint (104 columns), with department, section, sub_subsection, legacy_*
|
||||||
|
│ ├── forms.py # PublicComplaintForm, ComplaintForm, InquiryForm, GovernmentTicketForm (legacy cascade)
|
||||||
|
│ ├── ui_views.py # complaint_list, complaint_detail, complaint_send_to, complaint_escalate, inquiry_escalate, government_ticket_*
|
||||||
|
│ ├── services/complaint_service.py # ComplaintService.send_to_department
|
||||||
|
│ └── tasks.py # Auto-escalation disabled, check_overdue still active
|
||||||
|
├── observations/
|
||||||
|
│ ├── views.py # observation_escalate, observation_send_to
|
||||||
|
│ └── services.py # create_observation (accepts section, sub_subsection)
|
||||||
|
├── dashboard/services/
|
||||||
|
│ ├── complaint_monthly_service.py # Uses location, main_section FK
|
||||||
|
│ ├── complaint_quarterly_service.py # IP/OP/ER via location__name_en string matching
|
||||||
|
│ └── complaint_monthly_export.py # Exports c.location.name
|
||||||
|
├── analytics/services/
|
||||||
|
│ └── kpi_service.py # Queries location + main_section FKs
|
||||||
|
└── core/views.py # public_inquiry_submit, public_observation_submit
|
||||||
|
|
||||||
|
templates/
|
||||||
|
├── complaints/
|
||||||
|
│ ├── public_complaint_form.html # MIGRATED: Category→Department→Section→SubSection
|
||||||
|
│ ├── complaint_form.html # NOT MIGRATED: Still uses old Location→MainSection→SubSection
|
||||||
|
│ ├── inquiry_form.html # NOT MIGRATED: Still uses old hierarchy
|
||||||
|
│ ├── government_ticket_form.html # NOT MIGRATED: Still uses old hierarchy
|
||||||
|
│ ├── government_ticket_list.html # NOT MIGRATED: Displays legacy_main_section.name_en
|
||||||
|
│ ├── government_ticket_detail.html # NOT MIGRATED: Displays legacy_* fields
|
||||||
|
│ ├── complaint_detail.html # NOT MIGRATED: Displays legacy_* fields
|
||||||
|
│ ├── complaint_pdf.html # NOT MIGRATED: Displays legacy_* fields
|
||||||
|
│ └── inquiry_detail.html # NOT MIGRATED: Displays legacy_* fields
|
||||||
|
├── core/
|
||||||
|
│ └── public_submit.html # MIGRATED: 4 forms switched to new hierarchy
|
||||||
|
├── components/
|
||||||
|
│ └── send_to_modal.html # MIGRATED: Contact person dropdown from role holders
|
||||||
|
└── organizations/
|
||||||
|
└── department_detail.html # MIGRATED: Roles tab with #roleModal
|
||||||
|
|
||||||
|
DB: db.sqlite3 (production copy)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### If `showmigrations` shows pending migrations
|
||||||
|
```bash
|
||||||
|
python manage.py showmigrations --skip-checks --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### If new tables are missing
|
||||||
|
Tables must be created manually (not via Django migrations):
|
||||||
|
```python
|
||||||
|
# Check if tables exist
|
||||||
|
from django.db import connection
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'organizations_%'")
|
||||||
|
```
|
||||||
|
|
||||||
|
### If backfill shows 0 candidates
|
||||||
|
Check that `legacy_subsection_id` is set on complaints. Run the legacy FK backfill first.
|
||||||
|
|
||||||
|
### If import doesn't match existing departments
|
||||||
|
The import matches by `hospital + name_en`. Check the Excel column headers match what the command expects.
|
||||||
|
|
||||||
|
### System check errors
|
||||||
|
There are 4 pre-existing system check errors from `accounts.User.groups` vs `auth.User.groups` reverse accessor clash. These are NOT related to the hierarchy migration.
|
||||||
@ -40,11 +40,34 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
# Apps
|
# Apps
|
||||||
"apps.core",
|
"apps.core",
|
||||||
"apps.accounts",
|
"apps.accounts",
|
||||||
|
"apps.organizations",
|
||||||
|
"apps.complaints",
|
||||||
|
"apps.observations",
|
||||||
|
"apps.feedback",
|
||||||
|
"apps.appreciation",
|
||||||
"apps.dashboard",
|
"apps.dashboard",
|
||||||
"apps.social",
|
"apps.social",
|
||||||
|
"apps.px_sources",
|
||||||
|
"apps.analytics",
|
||||||
|
"apps.notifications",
|
||||||
|
"apps.surveys",
|
||||||
|
"apps.ai_engine",
|
||||||
|
"apps.callcenter",
|
||||||
|
"apps.executive_summary",
|
||||||
|
"apps.integrations",
|
||||||
|
"apps.journeys",
|
||||||
|
"apps.physicians",
|
||||||
|
"apps.presentations",
|
||||||
|
"apps.projects",
|
||||||
|
"apps.px_action_center",
|
||||||
|
"apps.rca",
|
||||||
|
"apps.references",
|
||||||
|
"apps.simulator",
|
||||||
|
"apps.standards",
|
||||||
"django_celery_beat",
|
"django_celery_beat",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -156,7 +179,6 @@ YOUTUBE_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/YT/"
|
|||||||
# Ensure you have your client_secrets.json file at this location
|
# Ensure you have your client_secrets.json file at this location
|
||||||
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
|
||||||
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
|
||||||
m
|
|
||||||
|
|
||||||
# Data upload settings
|
# Data upload settings
|
||||||
# Increased limit to support bulk patient imports from HIS
|
# Increased limit to support bulk patient imports from HIS
|
||||||
|
|||||||
@ -34,12 +34,6 @@ class Command(BaseCommand):
|
|||||||
"description": "Department-level access. Can manage their department.",
|
"description": "Department-level access. Can manage their department.",
|
||||||
"level": 60,
|
"level": 60,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "champion",
|
|
||||||
"display_name": "Champion",
|
|
||||||
"description": "Can respond to inquiries and view complaints for their assigned department.",
|
|
||||||
"level": 55,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "director",
|
"name": "director",
|
||||||
"display_name": "Director",
|
"display_name": "Director",
|
||||||
|
|||||||
18
apps/accounts/migrations/0003_alter_role_name.py
Normal file
18
apps/accounts/migrations/0003_alter_role_name.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='role',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(choices=[('px_admin', 'PX Admin'), ('hospital_admin', 'Hospital Admin'), ('department_manager', 'Department Manager'), ('director', 'Director'), ('px_management', 'PX Management'), ('px_employee', 'PX Employee'), ('staff', 'Staff'), ('viewer', 'Viewer'), ('executive', 'Executive')], max_length=50, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -155,8 +155,20 @@ class User(AbstractUser, TimeStampedModel):
|
|||||||
return self.has_role("Department Manager")
|
return self.has_role("Department Manager")
|
||||||
|
|
||||||
def is_champion(self):
|
def is_champion(self):
|
||||||
"""Check if user is Champion"""
|
"""Check if user is Champion (assigned as champion on any department)"""
|
||||||
return self.has_role("Champion")
|
return hasattr(self, 'staff_profile') and self.staff_profile is not None and self.staff_profile.champion_departments.exists()
|
||||||
|
|
||||||
|
def is_champion_of(self, department):
|
||||||
|
"""Check if user is Champion of a specific department"""
|
||||||
|
return (
|
||||||
|
hasattr(self, 'staff_profile')
|
||||||
|
and self.staff_profile is not None
|
||||||
|
and self.staff_profile.champion_departments.filter(pk=department.pk).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_department_respondent(self):
|
||||||
|
"""Alias for is_champion() - Champion is the department respondent role"""
|
||||||
|
return self.is_champion()
|
||||||
|
|
||||||
def is_px_management(self):
|
def is_px_management(self):
|
||||||
"""Check if user is PX Management"""
|
"""Check if user is PX Management"""
|
||||||
@ -201,9 +213,9 @@ class User(AbstractUser, TimeStampedModel):
|
|||||||
"""Check if user only has basic Staff role with no elevated permissions."""
|
"""Check if user only has basic Staff role with no elevated permissions."""
|
||||||
elevated_roles = [
|
elevated_roles = [
|
||||||
"PX Admin", "Hospital Admin", "Department Manager",
|
"PX Admin", "Hospital Admin", "Department Manager",
|
||||||
"Champion", "PX Management", "PX Employee", "Executive", "Director",
|
"PX Management", "PX Employee", "Executive", "Director",
|
||||||
]
|
]
|
||||||
return not any(self.has_role(r) for r in elevated_roles)
|
return not any(self.has_role(r) for r in elevated_roles) and not self.is_champion()
|
||||||
|
|
||||||
def get_source_user_profile_active(self):
|
def get_source_user_profile_active(self):
|
||||||
"""Get active source user profile if exists"""
|
"""Get active source user profile if exists"""
|
||||||
@ -496,7 +508,6 @@ class Role(models.Model):
|
|||||||
("hospital_admin", _("Hospital Admin")),
|
("hospital_admin", _("Hospital Admin")),
|
||||||
("department_manager", _("Department Manager")),
|
("department_manager", _("Department Manager")),
|
||||||
("director", _("Director")),
|
("director", _("Director")),
|
||||||
("champion", _("Champion")),
|
|
||||||
("px_management", _("PX Management")),
|
("px_management", _("PX Management")),
|
||||||
("px_employee", _("PX Employee")),
|
("px_employee", _("PX Employee")),
|
||||||
("staff", _("Staff")),
|
("staff", _("Staff")),
|
||||||
|
|||||||
@ -315,6 +315,45 @@ class OnboardingService:
|
|||||||
return User.objects.filter(is_provisional=True, acknowledgement_completed=False).count()
|
return User.objects.filter(is_provisional=True, acknowledgement_completed=False).count()
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetTokenService:
|
||||||
|
"""Service for one-time admin password reset links."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_reset_token(user):
|
||||||
|
if user.is_provisional:
|
||||||
|
raise ValueError("Provisional users should use the onboarding invitation flow.")
|
||||||
|
|
||||||
|
user.invitation_token = secrets.token_urlsafe(32)
|
||||||
|
user.invitation_expires_at = timezone.now() + timedelta(hours=24)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save(update_fields=["invitation_token", "invitation_expires_at", "password"])
|
||||||
|
return user.invitation_token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_reset_token(token):
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
try:
|
||||||
|
return User.objects.get(
|
||||||
|
invitation_token=token,
|
||||||
|
invitation_expires_at__gte=timezone.now(),
|
||||||
|
is_provisional=False,
|
||||||
|
)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_reset_token(user):
|
||||||
|
user.invitation_token = None
|
||||||
|
user.invitation_expires_at = None
|
||||||
|
user.save(update_fields=["invitation_token", "invitation_expires_at"])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_reset_url(base_url, token):
|
||||||
|
return f"{base_url.rstrip('/')}/accounts/password/reset/{token}/"
|
||||||
|
|
||||||
|
|
||||||
class EmailService:
|
class EmailService:
|
||||||
"""Service for sending onboarding-related emails"""
|
"""Service for sending onboarding-related emails"""
|
||||||
|
|
||||||
@ -333,12 +372,16 @@ class EmailService:
|
|||||||
# Build activation URL
|
# Build activation URL
|
||||||
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
||||||
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
||||||
|
days_remaining = (
|
||||||
|
max((user.invitation_expires_at - timezone.now()).days, 0) if user.invitation_expires_at else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Render email content
|
# Render email content
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"activation_url": activation_url,
|
"activation_url": activation_url,
|
||||||
"expires_at": user.invitation_expires_at,
|
"expires_at": user.invitation_expires_at,
|
||||||
|
"days_remaining": days_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = render_to_string("accounts/onboarding/invitation_subject.txt", context).strip()
|
subject = render_to_string("accounts/onboarding/invitation_subject.txt", context).strip()
|
||||||
@ -391,12 +434,16 @@ class EmailService:
|
|||||||
# Build activation URL
|
# Build activation URL
|
||||||
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
||||||
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
activation_url = f"{base_url}/accounts/onboarding/activate/{user.invitation_token}/"
|
||||||
|
days_remaining = (
|
||||||
|
max((user.invitation_expires_at - timezone.now()).days, 0) if user.invitation_expires_at else 0
|
||||||
|
)
|
||||||
|
|
||||||
# Render email content
|
# Render email content
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"activation_url": activation_url,
|
"activation_url": activation_url,
|
||||||
"expires_at": user.invitation_expires_at,
|
"expires_at": user.invitation_expires_at,
|
||||||
|
"days_remaining": days_remaining,
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = render_to_string("accounts/onboarding/reminder_subject.txt", context).strip()
|
subject = render_to_string("accounts/onboarding/reminder_subject.txt", context).strip()
|
||||||
@ -443,10 +490,15 @@ class EmailService:
|
|||||||
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
|
||||||
user_detail_url = f"{base_url}/accounts/onboarding/provisional/{user.id}/progress/"
|
user_detail_url = f"{base_url}/accounts/onboarding/provisional/{user.id}/progress/"
|
||||||
|
|
||||||
|
role_display = ", ".join(user.get_role_names()) if hasattr(user, "get_role_names") else ""
|
||||||
|
completed_at = user.acknowledgement_completed_at or timezone.now()
|
||||||
|
|
||||||
# Render email content
|
# Render email content
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"user_detail_url": user_detail_url,
|
"user_detail_url": user_detail_url,
|
||||||
|
"role_display": role_display,
|
||||||
|
"completed_at": completed_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = render_to_string("accounts/onboarding/completion_subject.txt", context).strip()
|
subject = render_to_string("accounts/onboarding/completion_subject.txt", context).strip()
|
||||||
|
|||||||
@ -25,7 +25,7 @@ from .models import (
|
|||||||
UserProvisionalLog,
|
UserProvisionalLog,
|
||||||
)
|
)
|
||||||
from .permissions import IsPXAdmin, CanManageOnboarding, CanViewOnboarding
|
from .permissions import IsPXAdmin, CanManageOnboarding, CanViewOnboarding
|
||||||
from .services import OnboardingService
|
from .services import OnboardingService, PasswordResetTokenService
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@ -152,6 +152,28 @@ def password_reset_view(request):
|
|||||||
return render(request, "accounts/password_reset.html", context)
|
return render(request, "accounts/password_reset.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@never_cache
|
||||||
|
def password_reset_token_view(request, token):
|
||||||
|
user = PasswordResetTokenService.validate_reset_token(token)
|
||||||
|
if user is None:
|
||||||
|
messages.error(request, "Invalid or expired password reset link. Please request a new one.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
messages.error(request, "This account is inactive. Please contact your administrator.")
|
||||||
|
return redirect("accounts:login")
|
||||||
|
|
||||||
|
PasswordResetTokenService.clear_reset_token(user)
|
||||||
|
|
||||||
|
from django.contrib.auth.backends import ModelBackend
|
||||||
|
|
||||||
|
backend = ModelBackend()
|
||||||
|
user.backend = f"{backend.__module__}.{backend.__class__.__name__}"
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, "Please set a new password for your account.")
|
||||||
|
return redirect("accounts:password_change")
|
||||||
|
|
||||||
|
|
||||||
class CustomPasswordResetConfirmView(PasswordResetConfirmView):
|
class CustomPasswordResetConfirmView(PasswordResetConfirmView):
|
||||||
"""
|
"""
|
||||||
Custom password reset confirm view with custom template
|
Custom password reset confirm view with custom template
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from .ui_views import (
|
|||||||
onboarding_step_content,
|
onboarding_step_content,
|
||||||
onboarding_welcome,
|
onboarding_welcome,
|
||||||
password_reset_view,
|
password_reset_view,
|
||||||
|
password_reset_token_view,
|
||||||
preview_wizard_as_role,
|
preview_wizard_as_role,
|
||||||
provisional_user_list,
|
provisional_user_list,
|
||||||
provisional_user_progress,
|
provisional_user_progress,
|
||||||
@ -58,6 +59,7 @@ urlpatterns = [
|
|||||||
path("logout/", logout_view, name="logout"),
|
path("logout/", logout_view, name="logout"),
|
||||||
path("settings/", user_settings, name="settings"),
|
path("settings/", user_settings, name="settings"),
|
||||||
path("password/reset/", password_reset_view, name="password_reset"),
|
path("password/reset/", password_reset_view, name="password_reset"),
|
||||||
|
path("password/reset/<str:token>/", password_reset_token_view, name="password_reset_token"),
|
||||||
path(
|
path(
|
||||||
"password/reset/confirm/<uidb64>/<token>/",
|
"password/reset/confirm/<uidb64>/<token>/",
|
||||||
CustomPasswordResetConfirmView.as_view(),
|
CustomPasswordResetConfirmView.as_view(),
|
||||||
|
|||||||
@ -27,6 +27,11 @@ from .kpi_models import (
|
|||||||
KPIReportType,
|
KPIReportType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(year, month, day=1):
|
||||||
|
"""Create a timezone-aware datetime to avoid naive datetime warnings."""
|
||||||
|
return timezone.make_aware(datetime(year, month, day))
|
||||||
|
|
||||||
DEPARTMENT_CATEGORY_KEYWORDS = {
|
DEPARTMENT_CATEGORY_KEYWORDS = {
|
||||||
"medical": [
|
"medical": [
|
||||||
"medical",
|
"medical",
|
||||||
@ -256,25 +261,25 @@ class KPICalculationService:
|
|||||||
def _calculate_72h_resolution(cls, report: KPIReport):
|
def _calculate_72h_resolution(cls, report: KPIReport):
|
||||||
"""Calculate 72-Hour Resolution Rate (MOH-2)"""
|
"""Calculate 72-Hour Resolution Rate (MOH-2)"""
|
||||||
# Get date range for the report period
|
# Get date range for the report period
|
||||||
start_date = datetime(report.year, report.month, 1)
|
start_date = _dt(report.year, report.month, 1)
|
||||||
if report.month == 12:
|
if report.month == 12:
|
||||||
end_date = datetime(report.year + 1, 1, 1)
|
end_date = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
end_date = datetime(report.year, report.month + 1, 1)
|
end_date = _dt(report.year, report.month + 1, 1)
|
||||||
|
|
||||||
# Get all months data for YTD (year to date)
|
# Get all months data for YTD (year to date)
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
|
|
||||||
# Calculate for each month
|
# Calculate for each month
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
# Get complaints for this month
|
# Get complaints for this month
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
@ -340,22 +345,22 @@ class KPICalculationService:
|
|||||||
def _calculate_patient_experience(cls, report: KPIReport):
|
def _calculate_patient_experience(cls, report: KPIReport):
|
||||||
"""Calculate Patient Experience Score (MOH-1)"""
|
"""Calculate Patient Experience Score (MOH-1)"""
|
||||||
# Get date range
|
# Get date range
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
start_date = datetime(report.year, report.month, 1)
|
start_date = _dt(report.year, report.month, 1)
|
||||||
if report.month == 12:
|
if report.month == 12:
|
||||||
end_date = datetime(report.year + 1, 1, 1)
|
end_date = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
end_date = datetime(report.year, report.month + 1, 1)
|
end_date = _dt(report.year, report.month + 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
# Get completed surveys for patient experience
|
# Get completed surveys for patient experience
|
||||||
surveys = SurveyInstance.objects.filter(
|
surveys = SurveyInstance.objects.filter(
|
||||||
@ -406,17 +411,17 @@ class KPICalculationService:
|
|||||||
- Denominator: complaints with satisfaction in (satisfied, neutral, dissatisfied)
|
- Denominator: complaints with satisfaction in (satisfied, neutral, dissatisfied)
|
||||||
- Numerator: complaints with satisfaction = 'satisfied'
|
- Numerator: complaints with satisfaction = 'satisfied'
|
||||||
"""
|
"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -457,7 +462,7 @@ class KPICalculationService:
|
|||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
created_at__gte=year_start,
|
created_at__gte=year_start,
|
||||||
created_at__lt=(
|
created_at__lt=(
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
),
|
),
|
||||||
complaint_type="complaint",
|
complaint_type="complaint",
|
||||||
)
|
)
|
||||||
@ -468,21 +473,21 @@ class KPICalculationService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _calculate_n_pad_001(cls, report: KPIReport):
|
def _calculate_n_pad_001(cls, report: KPIReport):
|
||||||
"""Calculate N-PAD-001 Resolution Rate"""
|
"""Calculate N-PAD-001 Resolution Rate"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
if report.month == 12:
|
if report.month == 12:
|
||||||
year_end = datetime(report.year + 1, 1, 1)
|
year_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
year_end = datetime(report.year, report.month + 1, 1)
|
year_end = _dt(report.year, report.month + 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -527,17 +532,17 @@ class KPICalculationService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _calculate_response_rate(cls, report: KPIReport):
|
def _calculate_response_rate(cls, report: KPIReport):
|
||||||
"""Calculate Department Response Rate (48h)"""
|
"""Calculate Department Response Rate (48h)"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -587,7 +592,7 @@ class KPICalculationService:
|
|||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
created_at__gte=year_start,
|
created_at__gte=year_start,
|
||||||
created_at__lt=(
|
created_at__lt=(
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
),
|
),
|
||||||
complaint_type="complaint",
|
complaint_type="complaint",
|
||||||
)
|
)
|
||||||
@ -598,17 +603,17 @@ class KPICalculationService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _calculate_activation_2h(cls, report: KPIReport):
|
def _calculate_activation_2h(cls, report: KPIReport):
|
||||||
"""Calculate Complaint Activation Within 2 Hours"""
|
"""Calculate Complaint Activation Within 2 Hours"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
# Get complaints with assigned_to (activated)
|
# Get complaints with assigned_to (activated)
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
@ -655,7 +660,7 @@ class KPICalculationService:
|
|||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
created_at__gte=year_start,
|
created_at__gte=year_start,
|
||||||
created_at__lt=(
|
created_at__lt=(
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
),
|
),
|
||||||
complaint_type="complaint",
|
complaint_type="complaint",
|
||||||
)
|
)
|
||||||
@ -668,17 +673,17 @@ class KPICalculationService:
|
|||||||
"""Calculate Unactivated Filled Complaints Rate"""
|
"""Calculate Unactivated Filled Complaints Rate"""
|
||||||
from apps.dashboard.models import ComplaintRequest
|
from apps.dashboard.models import ComplaintRequest
|
||||||
|
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -725,7 +730,7 @@ class KPICalculationService:
|
|||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
created_at__gte=year_start,
|
created_at__gte=year_start,
|
||||||
created_at__lt=(
|
created_at__lt=(
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
),
|
),
|
||||||
complaint_type="complaint",
|
complaint_type="complaint",
|
||||||
)
|
)
|
||||||
@ -736,21 +741,21 @@ class KPICalculationService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _calculate_moh_24h(cls, report: KPIReport):
|
def _calculate_moh_24h(cls, report: KPIReport):
|
||||||
"""Calculate 24-Hour MOH Complaint Resolution Rate"""
|
"""Calculate 24-Hour MOH Complaint Resolution Rate"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
if report.month == 12:
|
if report.month == 12:
|
||||||
end_date = datetime(report.year + 1, 1, 1)
|
end_date = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
end_date = datetime(report.year, report.month + 1, 1)
|
end_date = _dt(report.year, report.month + 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -802,21 +807,21 @@ class KPICalculationService:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def _calculate_chi_48h(cls, report: KPIReport):
|
def _calculate_chi_48h(cls, report: KPIReport):
|
||||||
"""Calculate 48-Hour CHI Complaint Resolution Rate"""
|
"""Calculate 48-Hour CHI Complaint Resolution Rate"""
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
if report.month == 12:
|
if report.month == 12:
|
||||||
end_date = datetime(report.year + 1, 1, 1)
|
end_date = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
end_date = datetime(report.year, report.month + 1, 1)
|
end_date = _dt(report.year, report.month + 1, 1)
|
||||||
|
|
||||||
total_numerator = 0
|
total_numerator = 0
|
||||||
total_denominator = 0
|
total_denominator = 0
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
month_start = datetime(report.year, month, 1)
|
month_start = _dt(report.year, month, 1)
|
||||||
if month == 12:
|
if month == 12:
|
||||||
month_end = datetime(report.year + 1, 1, 1)
|
month_end = _dt(report.year + 1, 1, 1)
|
||||||
else:
|
else:
|
||||||
month_end = datetime(report.year, month + 1, 1)
|
month_end = _dt(report.year, month + 1, 1)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
hospital=report.hospital,
|
hospital=report.hospital,
|
||||||
@ -918,7 +923,9 @@ class KPICalculationService:
|
|||||||
).count()
|
).count()
|
||||||
|
|
||||||
avg_days = None
|
avg_days = None
|
||||||
resolved_complaints = dept_complaints.filter(resolved_at__isnull=False)
|
resolved_complaints = dept_complaints.filter(
|
||||||
|
resolved_at__isnull=False, activated_at__isnull=False
|
||||||
|
)
|
||||||
if resolved_complaints.exists():
|
if resolved_complaints.exists():
|
||||||
total_days = 0
|
total_days = 0
|
||||||
for c in resolved_complaints:
|
for c in resolved_complaints:
|
||||||
@ -963,8 +970,7 @@ class KPICalculationService:
|
|||||||
for loc_type, keywords in location_categories.items():
|
for loc_type, keywords in location_categories.items():
|
||||||
q_objects = Q()
|
q_objects = Q()
|
||||||
for keyword in keywords:
|
for keyword in keywords:
|
||||||
q_objects |= Q(location__name_en__icontains=keyword)
|
q_objects |= Q(department__location_type__icontains=keyword)
|
||||||
q_objects |= Q(main_section__name_en__icontains=keyword)
|
|
||||||
|
|
||||||
loc_complaints = complaints.filter(q_objects).distinct()
|
loc_complaints = complaints.filter(q_objects).distinct()
|
||||||
count = loc_complaints.count()
|
count = loc_complaints.count()
|
||||||
@ -1058,9 +1064,9 @@ class KPICalculationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Calculate resolution time buckets
|
# Calculate resolution time buckets
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
year_end = (
|
year_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
all_complaints = Complaint.objects.filter(
|
all_complaints = Complaint.objects.filter(
|
||||||
@ -1350,9 +1356,9 @@ Be specific and use actual numbers from the data."""
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
year_end = (
|
year_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.db.models import Avg as AvgAgg, Count
|
from django.db.models import Avg as AvgAgg, Count
|
||||||
@ -1526,9 +1532,9 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
|
|||||||
resolved_complaints = report.total_numerator
|
resolved_complaints = report.total_numerator
|
||||||
|
|
||||||
# Get date range
|
# Get date range
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
year_end = (
|
year_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Query complaints for detailed analysis
|
# Query complaints for detailed analysis
|
||||||
@ -1537,7 +1543,7 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
|
|||||||
created_at__gte=year_start,
|
created_at__gte=year_start,
|
||||||
created_at__lt=year_end,
|
created_at__lt=year_end,
|
||||||
complaint_type="complaint",
|
complaint_type="complaint",
|
||||||
).select_related("department", "location", "main_section", "source")
|
).select_related("department", "source")
|
||||||
|
|
||||||
# Count by status
|
# Count by status
|
||||||
closed_count = complaints.filter(status=ComplaintStatus.CLOSED).count()
|
closed_count = complaints.filter(status=ComplaintStatus.CLOSED).count()
|
||||||
@ -1620,7 +1626,7 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
|
|||||||
# Location breakdown
|
# Location breakdown
|
||||||
location_counts = {}
|
location_counts = {}
|
||||||
for c in complaints:
|
for c in complaints:
|
||||||
loc = c.location.name_en if c.location else "Unknown"
|
loc = c.department.location_type if c.department and c.department.location_type else "Unknown"
|
||||||
location_counts[loc] = location_counts.get(loc, 0) + 1
|
location_counts[loc] = location_counts.get(loc, 0) + 1
|
||||||
|
|
||||||
# Main department breakdown
|
# Main department breakdown
|
||||||
@ -1799,9 +1805,9 @@ Be specific with numbers and focus on actionable insights."""
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
year_end = (
|
year_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
total_complaints_received = Complaint.objects.filter(
|
total_complaints_received = Complaint.objects.filter(
|
||||||
@ -2020,9 +2026,9 @@ Focus on identifying why patients are dissatisfied and provide practical solutio
|
|||||||
except KPIReport.DoesNotExist:
|
except KPIReport.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
year_start = datetime(report.year, 1, 1)
|
year_start = _dt(report.year, 1, 1)
|
||||||
year_end = (
|
year_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
all_complaints = (
|
all_complaints = (
|
||||||
@ -2215,9 +2221,9 @@ Focus on identifying which departments need improvement and practical follow-up
|
|||||||
total_complaints = report.total_denominator
|
total_complaints = report.total_denominator
|
||||||
activated_within_2h = report.total_numerator
|
activated_within_2h = report.total_numerator
|
||||||
|
|
||||||
month_start = datetime(report.year, report.month, 1)
|
month_start = _dt(report.year, report.month, 1)
|
||||||
month_end = (
|
month_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
complaints = Complaint.objects.filter(
|
complaints = Complaint.objects.filter(
|
||||||
@ -2388,9 +2394,9 @@ Focus on identifying why activations are delayed and practical solutions."""
|
|||||||
total_complaints = report.total_denominator
|
total_complaints = report.total_denominator
|
||||||
unactivated_filled = report.total_numerator
|
unactivated_filled = report.total_numerator
|
||||||
|
|
||||||
month_start = datetime(report.year, report.month, 1)
|
month_start = _dt(report.year, report.month, 1)
|
||||||
month_end = (
|
month_end = (
|
||||||
datetime(report.year + 1, 1, 1) if report.month == 12 else datetime(report.year, report.month + 1, 1)
|
_dt(report.year + 1, 1, 1) if report.month == 12 else _dt(report.year, report.month + 1, 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
all_requests = ComplaintRequest.objects.filter(
|
all_requests = ComplaintRequest.objects.filter(
|
||||||
|
|||||||
@ -26,7 +26,7 @@ from apps.analytics.kpi_models import KPIReport, KPIReportType
|
|||||||
from apps.analytics.kpi_service import KPICalculationService
|
from apps.analytics.kpi_service import KPICalculationService
|
||||||
from apps.complaints.models import Complaint, ComplaintStatus, ComplaintUpdate
|
from apps.complaints.models import Complaint, ComplaintStatus, ComplaintUpdate
|
||||||
from apps.dashboard.models import ComplaintRequest
|
from apps.dashboard.models import ComplaintRequest
|
||||||
from apps.organizations.models import Department, Hospital, Location, MainSection
|
from apps.organizations.models import Department, Hospital, LegacyLocation, LegacyMainSection
|
||||||
from apps.organizations.models import Patient
|
from apps.organizations.models import Patient
|
||||||
from apps.px_sources.models import PXSource
|
from apps.px_sources.models import PXSource
|
||||||
from apps.surveys.models import (
|
from apps.surveys.models import (
|
||||||
@ -217,19 +217,19 @@ class Command(BaseCommand):
|
|||||||
defaults={"name": d["name"], "status": "active"},
|
defaults={"name": d["name"], "status": "active"},
|
||||||
)
|
)
|
||||||
for loc in LOCATIONS:
|
for loc in LOCATIONS:
|
||||||
Location.objects.get_or_create(
|
LegacyLocation.objects.get_or_create(
|
||||||
id=loc["id"],
|
id=loc["id"],
|
||||||
defaults={"name_en": loc["name_en"], "name_ar": loc["name_ar"]},
|
defaults={"name_en": loc["name_en"], "name_ar": loc["name_ar"]},
|
||||||
)
|
)
|
||||||
for ms in MAIN_SECTIONS:
|
for ms in MAIN_SECTIONS:
|
||||||
MainSection.objects.get_or_create(
|
LegacyMainSection.objects.get_or_create(
|
||||||
id=ms["id"],
|
id=ms["id"],
|
||||||
defaults={"name_en": ms["name_en"], "name_ar": ms["name_ar"]},
|
defaults={"name_en": ms["name_en"], "name_ar": ms["name_ar"]},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f" Depts: {Department.objects.filter(hospital=hospital).count()}, "
|
f" Depts: {Department.objects.filter(hospital=hospital).count()}, "
|
||||||
f"Locs: {Location.objects.filter(id__in=[l['id'] for l in LOCATIONS]).count()}"
|
f"Locs: {LegacyLocation.objects.filter(id__in=[l['id'] for l in LOCATIONS]).count()}"
|
||||||
)
|
)
|
||||||
return hospital
|
return hospital
|
||||||
|
|
||||||
@ -335,8 +335,8 @@ class Command(BaseCommand):
|
|||||||
complaints = []
|
complaints = []
|
||||||
source_list = list(sources.values())
|
source_list = list(sources.values())
|
||||||
departments = list(Department.objects.filter(hospital=hospital))
|
departments = list(Department.objects.filter(hospital=hospital))
|
||||||
locations = list(Location.objects.filter(id__in=[l["id"] for l in LOCATIONS]))
|
locations = list(LegacyLocation.objects.filter(id__in=[l["id"] for l in LOCATIONS]))
|
||||||
main_sections = list(MainSection.objects.filter(id__in=[m["id"] for m in MAIN_SECTIONS]))
|
main_sections = list(LegacyMainSection.objects.filter(id__in=[m["id"] for m in MAIN_SECTIONS]))
|
||||||
|
|
||||||
for i in range(self.complaints_per_month):
|
for i in range(self.complaints_per_month):
|
||||||
created_at = self._random_dt(month)
|
created_at = self._random_dt(month)
|
||||||
@ -378,8 +378,8 @@ class Command(BaseCommand):
|
|||||||
complaint = Complaint(
|
complaint = Complaint(
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
department=department,
|
department=department,
|
||||||
location=location,
|
legacy_location=location,
|
||||||
main_section=main_section,
|
legacy_main_section=main_section,
|
||||||
source=source,
|
source=source,
|
||||||
title=f"{random.choice(COMPLAINT_TITLES)} ({month}/{i + 1})",
|
title=f"{random.choice(COMPLAINT_TITLES)} ({month}/{i + 1})",
|
||||||
description=random.choice(COMPLAINT_DESCRIPTIONS),
|
description=random.choice(COMPLAINT_DESCRIPTIONS),
|
||||||
|
|||||||
@ -125,11 +125,23 @@ class UnifiedAnalyticsService:
|
|||||||
# Check if queryset has hospital/department fields
|
# Check if queryset has hospital/department fields
|
||||||
if hasattr(queryset.model, "hospital"):
|
if hasattr(queryset.model, "hospital"):
|
||||||
if user.is_px_admin():
|
if user.is_px_admin():
|
||||||
pass # See all
|
pass
|
||||||
elif user.is_hospital_admin() and user.hospital:
|
elif user.is_hospital_admin() and user.hospital:
|
||||||
queryset = queryset.filter(hospital=user.hospital)
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.is_executive() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
elif user.is_department_manager() and user.department:
|
elif user.is_department_manager() and user.department:
|
||||||
queryset = queryset.filter(department=user.department)
|
queryset = queryset.filter(department=user.department)
|
||||||
|
elif user.is_px_management() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.is_px_employee() and user.hospital:
|
||||||
|
queryset = queryset.filter(hospital=user.hospital)
|
||||||
|
elif user.is_director():
|
||||||
|
directed_depts = user.get_directed_departments()
|
||||||
|
if directed_depts.exists():
|
||||||
|
queryset = queryset.filter(department__in=directed_depts)
|
||||||
|
else:
|
||||||
|
queryset = queryset.none()
|
||||||
else:
|
else:
|
||||||
queryset = queryset.none()
|
queryset = queryset.none()
|
||||||
return queryset
|
return queryset
|
||||||
|
|||||||
@ -173,6 +173,69 @@ def analytics_dashboard(request):
|
|||||||
# Status breakdown
|
# Status breakdown
|
||||||
status_breakdown = status_counts_qs.order_by("-count")
|
status_breakdown = status_counts_qs.order_by("-count")
|
||||||
|
|
||||||
|
# Complaints by department (top 10)
|
||||||
|
complaints_by_dept = (
|
||||||
|
complaints_queryset.filter(department__isnull=False)
|
||||||
|
.values("department__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============ INQUIRY ANALYTICS ============
|
||||||
|
inquiry_status_counts = inquiry_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
||||||
|
inquiry_priority_counts = inquiry_queryset.exclude(priority="").values("priority").annotate(count=Count("id")).order_by("-count")
|
||||||
|
inquiries_by_dept = (
|
||||||
|
inquiry_queryset.filter(department__isnull=False)
|
||||||
|
.values("department__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
inquiries_by_category = inquiry_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:10]
|
||||||
|
|
||||||
|
# ============ OBSERVATION ANALYTICS ============
|
||||||
|
observation_status_counts = observation_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
||||||
|
observation_severity_counts = observation_queryset.values("severity").annotate(count=Count("id")).order_by("-count")
|
||||||
|
observations_by_dept = (
|
||||||
|
observation_queryset.filter(assigned_department__isnull=False)
|
||||||
|
.values("assigned_department__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
observations_by_category = (
|
||||||
|
observation_queryset.filter(category__isnull=False)
|
||||||
|
.values("category__name_en")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============ SUGGESTION ANALYTICS (Feedback type=suggestion) ============
|
||||||
|
suggestion_queryset = feedback_queryset.filter(feedback_type="suggestion")
|
||||||
|
suggestion_status_counts = suggestion_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
||||||
|
suggestion_sentiment_counts = suggestion_queryset.exclude(sentiment="").values("sentiment").annotate(count=Count("id")).order_by("-count")
|
||||||
|
suggestions_by_dept = (
|
||||||
|
suggestion_queryset.filter(department__isnull=False)
|
||||||
|
.values("department__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
suggestions_by_category = suggestion_queryset.exclude(category="").values("category").annotate(count=Count("id")).order_by("-count")[:10]
|
||||||
|
|
||||||
|
# ============ APPRECIATION ANALYTICS ============
|
||||||
|
appreciation_status_counts = appreciation_queryset.values("status").annotate(count=Count("id")).order_by("-count")
|
||||||
|
appreciations_by_dept = (
|
||||||
|
appreciation_queryset.filter(department__isnull=False)
|
||||||
|
.values("department__name")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
appreciations_by_category = (
|
||||||
|
appreciation_queryset.filter(category__isnull=False)
|
||||||
|
.values("category__name_en")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")[:10]
|
||||||
|
)
|
||||||
|
appreciations_by_visibility = appreciation_queryset.values("visibility").annotate(count=Count("id")).order_by("-count")
|
||||||
|
|
||||||
# ============ ACTIONS KPIs ============
|
# ============ ACTIONS KPIs ============
|
||||||
action_status_counts = actions_queryset.values("status").annotate(count=Count("id"))
|
action_status_counts = actions_queryset.values("status").annotate(count=Count("id"))
|
||||||
action_status_map = {item["status"]: item["count"] for item in action_status_counts}
|
action_status_map = {item["status"]: item["count"] for item in action_status_counts}
|
||||||
@ -678,6 +741,23 @@ def analytics_dashboard(request):
|
|||||||
"top_categories": serialize_queryset_values(top_categories),
|
"top_categories": serialize_queryset_values(top_categories),
|
||||||
"severity_breakdown": serialize_queryset_values(severity_breakdown),
|
"severity_breakdown": serialize_queryset_values(severity_breakdown),
|
||||||
"status_breakdown": serialize_queryset_values(status_breakdown),
|
"status_breakdown": serialize_queryset_values(status_breakdown),
|
||||||
|
"complaints_by_dept": serialize_queryset_values(complaints_by_dept),
|
||||||
|
"inquiry_status_counts": serialize_queryset_values(inquiry_status_counts),
|
||||||
|
"inquiry_priority_counts": serialize_queryset_values(inquiry_priority_counts),
|
||||||
|
"inquiries_by_dept": serialize_queryset_values(inquiries_by_dept),
|
||||||
|
"inquiries_by_category": serialize_queryset_values(inquiries_by_category),
|
||||||
|
"observation_status_counts": serialize_queryset_values(observation_status_counts),
|
||||||
|
"observation_severity_counts": serialize_queryset_values(observation_severity_counts),
|
||||||
|
"observations_by_dept": serialize_queryset_values(observations_by_dept),
|
||||||
|
"observations_by_category": serialize_queryset_values(observations_by_category),
|
||||||
|
"suggestion_status_counts": serialize_queryset_values(suggestion_status_counts),
|
||||||
|
"suggestion_sentiment_counts": serialize_queryset_values(suggestion_sentiment_counts),
|
||||||
|
"suggestions_by_dept": serialize_queryset_values(suggestions_by_dept),
|
||||||
|
"suggestions_by_category": serialize_queryset_values(suggestions_by_category),
|
||||||
|
"appreciation_status_counts": serialize_queryset_values(appreciation_status_counts),
|
||||||
|
"appreciations_by_dept": serialize_queryset_values(appreciations_by_dept),
|
||||||
|
"appreciations_by_category": serialize_queryset_values(appreciations_by_category),
|
||||||
|
"appreciations_by_visibility": serialize_queryset_values(appreciations_by_visibility),
|
||||||
"complaint_trend": serialize_queryset_values(complaint_trend),
|
"complaint_trend": serialize_queryset_values(complaint_trend),
|
||||||
"complaints_by_quarter": serialize_queryset_values(complaints_by_quarter),
|
"complaints_by_quarter": serialize_queryset_values(complaints_by_quarter),
|
||||||
"inquiries_by_quarter": serialize_queryset_values(inquiries_by_quarter),
|
"inquiries_by_quarter": serialize_queryset_values(inquiries_by_quarter),
|
||||||
@ -909,8 +989,10 @@ def command_center(request):
|
|||||||
hospital_id = filters["hospital"] if filters["hospital"] else None
|
hospital_id = filters["hospital"] if filters["hospital"] else None
|
||||||
department_id = filters["department"] if filters["department"] else None
|
department_id = filters["department"] if filters["department"] else None
|
||||||
|
|
||||||
if not hospital_id and user.is_px_admin():
|
if not hospital_id and (user.is_px_admin() or user.is_executive()):
|
||||||
tenant = getattr(request, "tenant_hospital", None)
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if not tenant:
|
||||||
|
tenant = getattr(user, "hospital", None)
|
||||||
if tenant:
|
if tenant:
|
||||||
hospital_id = str(tenant.id)
|
hospital_id = str(tenant.id)
|
||||||
|
|
||||||
@ -1006,8 +1088,10 @@ def command_center_api(request):
|
|||||||
# Handle department_id (UUID string)
|
# Handle department_id (UUID string)
|
||||||
department_id = department_id if department_id else None
|
department_id = department_id if department_id else None
|
||||||
|
|
||||||
if not hospital_id and user.is_px_admin():
|
if not hospital_id and (user.is_px_admin() or user.is_executive()):
|
||||||
tenant = getattr(request, "tenant_hospital", None)
|
tenant = getattr(request, "tenant_hospital", None)
|
||||||
|
if not tenant:
|
||||||
|
tenant = getattr(user, "hospital", None)
|
||||||
if tenant:
|
if tenant:
|
||||||
hospital_id = str(tenant.id)
|
hospital_id = str(tenant.id)
|
||||||
|
|
||||||
|
|||||||
@ -73,6 +73,10 @@ class AppreciationAdmin(admin.ModelAdmin):
|
|||||||
'sender',
|
'sender',
|
||||||
'hospital',
|
'hospital',
|
||||||
'department',
|
'department',
|
||||||
|
'legacy_location',
|
||||||
|
'legacy_main_section',
|
||||||
|
'legacy_subsection',
|
||||||
|
'section',
|
||||||
'category',
|
'category',
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0004_legacylocation_legacymainsection_and_more'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -92,11 +92,11 @@ class Migration(migrations.Migration):
|
|||||||
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
('department', models.ForeignKey(blank=True, help_text='Department context (if applicable)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.department')),
|
('department', models.ForeignKey(blank=True, help_text='Department context (if applicable)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.department')),
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciations', to='organizations.hospital')),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='appreciations', to='organizations.hospital')),
|
||||||
('location', models.ForeignKey(blank=True, help_text='Location where the appreciation event occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.location')),
|
('location', models.ForeignKey(blank=True, help_text='Location where the appreciation event occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacylocation')),
|
||||||
('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.mainsection')),
|
('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacymainsection')),
|
||||||
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_recipients', to='contenttypes.contenttype')),
|
('recipient_content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciation_recipients', to='contenttypes.contenttype')),
|
||||||
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_appreciations', to=settings.AUTH_USER_MODEL)),
|
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_appreciations', to=settings.AUTH_USER_MODEL)),
|
||||||
('subsection', models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.subsection')),
|
('subsection', models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacysubsection')),
|
||||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='appreciation.appreciationcategory')),
|
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='appreciation.appreciationcategory')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('appreciation', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='appreciation',
|
||||||
|
old_name='location',
|
||||||
|
new_name='legacy_location',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='appreciation',
|
||||||
|
old_name='main_section',
|
||||||
|
new_name='legacy_main_section',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='appreciation',
|
||||||
|
old_name='subsection',
|
||||||
|
new_name='legacy_subsection',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('appreciation', '0002_remove_appreciation_location_and_more'),
|
||||||
|
('organizations', '0008_rename_orgsubsection_to_section_add_champion'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations_new', to='organizations.Section'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('appreciation', '0003_appreciation_legacy_location_and_more'),
|
||||||
|
('organizations', '0005_alter_legacylocation_table_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Location where the appreciation event occurred', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
10
apps/appreciation/migrations/0005_remove_sub_subsection.py
Normal file
10
apps/appreciation/migrations/0005_remove_sub_subsection.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('appreciation', '0004_alter_appreciation_legacy_location_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-14 10:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('appreciation', '0005_remove_sub_subsection'),
|
||||||
|
('organizations', '0014_remove_department_manager_1st'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='reference_number',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=40, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='appreciation',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appreciations', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -8,7 +8,7 @@ This module implements the appreciation system that:
|
|||||||
- Integrates with the notification system
|
- Integrates with the notification system
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
@ -25,6 +25,15 @@ class AppreciationStatus(models.TextChoices):
|
|||||||
ACKNOWLEDGED = "acknowledged", "Acknowledged"
|
ACKNOWLEDGED = "acknowledged", "Acknowledged"
|
||||||
|
|
||||||
|
|
||||||
|
VALID_APPRECIATION_TRANSITIONS = {
|
||||||
|
AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED},
|
||||||
|
AppreciationStatus.ACTIVATED: {AppreciationStatus.AI_ANALYZED, AppreciationStatus.SENT},
|
||||||
|
AppreciationStatus.AI_ANALYZED: {AppreciationStatus.SENT},
|
||||||
|
AppreciationStatus.SENT: {AppreciationStatus.ACKNOWLEDGED},
|
||||||
|
AppreciationStatus.ACKNOWLEDGED: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AppreciationVisibility(models.TextChoices):
|
class AppreciationVisibility(models.TextChoices):
|
||||||
"""Appreciation visibility choices"""
|
"""Appreciation visibility choices"""
|
||||||
|
|
||||||
@ -128,29 +137,38 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
# Organization context
|
# Organization context
|
||||||
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="appreciations")
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="appreciations")
|
||||||
location = models.ForeignKey(
|
legacy_location = models.ForeignKey(
|
||||||
"organizations.Location",
|
"organizations.LegacyLocation",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="appreciations",
|
related_name="appreciations",
|
||||||
help_text="Location where the appreciation event occurred",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
main_section = models.ForeignKey(
|
legacy_main_section = models.ForeignKey(
|
||||||
"organizations.MainSection",
|
"organizations.LegacyMainSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="appreciations",
|
related_name="appreciations",
|
||||||
help_text="Main section within the location",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
subsection = models.ForeignKey(
|
legacy_subsection = models.ForeignKey(
|
||||||
"organizations.SubSection",
|
"organizations.LegacySubSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="appreciations",
|
related_name="appreciations",
|
||||||
help_text="Specific subsection",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
|
)
|
||||||
|
# New hierarchy (from 4th Version Excel)
|
||||||
|
section = models.ForeignKey(
|
||||||
|
"organizations.Section",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="appreciations_new",
|
||||||
|
help_text="Section within department",
|
||||||
)
|
)
|
||||||
department = models.ForeignKey(
|
department = models.ForeignKey(
|
||||||
"organizations.Department",
|
"organizations.Department",
|
||||||
@ -176,6 +194,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
max_length=20, choices=AppreciationStatus.choices, default=AppreciationStatus.DRAFT, db_index=True
|
max_length=20, choices=AppreciationStatus.choices, default=AppreciationStatus.DRAFT, db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Reference number (unified format APR-YYYYMM-HOSP-NNNN; internal-only, not publicly trackable)
|
||||||
|
reference_number = models.CharField(max_length=40, unique=True, blank=True, null=True, db_index=True)
|
||||||
|
|
||||||
# Anonymous option
|
# Anonymous option
|
||||||
is_anonymous = models.BooleanField(default=False, help_text="Hide sender identity from recipient")
|
is_anonymous = models.BooleanField(default=False, help_text="Hide sender identity from recipient")
|
||||||
|
|
||||||
@ -202,6 +223,8 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
notes = GenericRelation("core.Note")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -217,6 +240,36 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
recipient_name = self.get_recipient_name()
|
recipient_name = self.get_recipient_name()
|
||||||
return f"Appreciation to {recipient_name} ({self.status})"
|
return f"Appreciation to {recipient_name} ({self.status})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.reference_number:
|
||||||
|
from apps.core.reference import generate_reference
|
||||||
|
|
||||||
|
self.reference_number = generate_reference("APR", self.hospital)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_owner(self):
|
||||||
|
"""
|
||||||
|
Returns the owner of this appreciation.
|
||||||
|
Cascade: section(champion, supervisor, deputy_supervisor)
|
||||||
|
-> department(champion, deputy_manager, supervisor,
|
||||||
|
deputy_supervisor, manager_2nd, manager_3rd).
|
||||||
|
Returns: Staff instance or None.
|
||||||
|
"""
|
||||||
|
if self.section:
|
||||||
|
for role in ("champion", "supervisor", "deputy_supervisor"):
|
||||||
|
owner = getattr(self.section, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
if self.department:
|
||||||
|
dept = self.department
|
||||||
|
for role in ("champion", "deputy_manager",
|
||||||
|
"supervisor", "deputy_supervisor",
|
||||||
|
"manager_2nd", "manager_3rd"):
|
||||||
|
owner = getattr(dept, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
def get_localized_message(self):
|
def get_localized_message(self):
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
@ -228,7 +281,7 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
"""Get recipient's name"""
|
"""Get recipient's name"""
|
||||||
try:
|
try:
|
||||||
return str(self.recipient)
|
return str(self.recipient)
|
||||||
except:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
def get_recipient_email(self):
|
def get_recipient_email(self):
|
||||||
@ -238,7 +291,7 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
return self.recipient.email
|
return self.recipient.email
|
||||||
elif hasattr(self.recipient, "user"):
|
elif hasattr(self.recipient, "user"):
|
||||||
return self.recipient.user.email
|
return self.recipient.user.email
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -249,13 +302,16 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
return self.recipient.phone
|
return self.recipient.phone
|
||||||
elif hasattr(self.recipient, "user"):
|
elif hasattr(self.recipient, "user"):
|
||||||
return self.recipient.user.phone
|
return self.recipient.user.phone
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def activate(self, activated_by=None):
|
def activate(self, activated_by=None):
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.status != AppreciationStatus.DRAFT:
|
||||||
|
raise ValueError(f"Cannot activate appreciation in '{self.status}' status. Must be in 'draft'.")
|
||||||
|
|
||||||
self.status = AppreciationStatus.ACTIVATED
|
self.status = AppreciationStatus.ACTIVATED
|
||||||
self.activated_at = timezone.now()
|
self.activated_at = timezone.now()
|
||||||
self.activated_by = activated_by
|
self.activated_by = activated_by
|
||||||
@ -264,6 +320,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
def mark_ai_analyzed(self, analysis_data):
|
def mark_ai_analyzed(self, analysis_data):
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.status != AppreciationStatus.ACTIVATED:
|
||||||
|
raise ValueError(f"Cannot mark as AI analyzed from '{self.status}' status. Must be in 'activated'.")
|
||||||
|
|
||||||
self.status = AppreciationStatus.AI_ANALYZED
|
self.status = AppreciationStatus.AI_ANALYZED
|
||||||
self.ai_analyzed_at = timezone.now()
|
self.ai_analyzed_at = timezone.now()
|
||||||
self.ai_analysis = analysis_data
|
self.ai_analysis = analysis_data
|
||||||
@ -273,6 +332,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
"""Send appreciation and trigger notification"""
|
"""Send appreciation and trigger notification"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.status not in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED):
|
||||||
|
raise ValueError(f"Cannot send appreciation in '{self.status}' status. Must be 'activated' or 'ai_analyzed'.")
|
||||||
|
|
||||||
self.status = AppreciationStatus.SENT
|
self.status = AppreciationStatus.SENT
|
||||||
self.sent_at = timezone.now()
|
self.sent_at = timezone.now()
|
||||||
self.save(update_fields=["status", "sent_at"])
|
self.save(update_fields=["status", "sent_at"])
|
||||||
@ -283,13 +345,15 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
"""Mark appreciation as acknowledged"""
|
"""Mark appreciation as acknowledged"""
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.status != AppreciationStatus.SENT:
|
||||||
|
raise ValueError(f"Cannot acknowledge appreciation in '{self.status}' status. Must be in 'sent'.")
|
||||||
|
|
||||||
self.status = AppreciationStatus.ACKNOWLEDGED
|
self.status = AppreciationStatus.ACKNOWLEDGED
|
||||||
self.acknowledged_at = timezone.now()
|
self.acknowledged_at = timezone.now()
|
||||||
self.save(update_fields=["status", "acknowledged_at"])
|
self.save(update_fields=["status", "acknowledged_at"])
|
||||||
|
|
||||||
def send_notification(self):
|
def send_notification(self):
|
||||||
"""Send notification to recipient"""
|
"""Send notification to recipient — handled by post_save signal."""
|
||||||
# This will be implemented in signals.py
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -405,7 +469,7 @@ class UserBadge(UUIDModel, TimeStampedModel):
|
|||||||
"""Get recipient's name"""
|
"""Get recipient's name"""
|
||||||
try:
|
try:
|
||||||
return str(self.recipient)
|
return str(self.recipient)
|
||||||
except:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
@ -464,5 +528,5 @@ class AppreciationStats(UUIDModel, TimeStampedModel):
|
|||||||
"""Get recipient's name"""
|
"""Get recipient's name"""
|
||||||
try:
|
try:
|
||||||
return str(self.recipient)
|
return str(self.recipient)
|
||||||
except:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|||||||
@ -6,11 +6,15 @@ This module handles:
|
|||||||
- Updating statistics when appreciations are created
|
- Updating statistics when appreciations are created
|
||||||
- Checking and awarding badges
|
- Checking and awarding badges
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from apps.appreciation.models import (
|
from apps.appreciation.models import (
|
||||||
Appreciation,
|
Appreciation,
|
||||||
AppreciationBadge,
|
AppreciationBadge,
|
||||||
@ -49,8 +53,9 @@ def send_appreciation_notification(appreciation):
|
|||||||
Uses the notification system to send email/SMS/WhatsApp.
|
Uses the notification system to send email/SMS/WhatsApp.
|
||||||
Also sends email to department heads.
|
Also sends email to department heads.
|
||||||
"""
|
"""
|
||||||
|
notification_success = False
|
||||||
try:
|
try:
|
||||||
from apps.notifications.services import send_email, send_sms
|
from apps.notifications.services import send_email, send_sms, get_email_header_html
|
||||||
|
|
||||||
recipient_email = appreciation.get_recipient_email()
|
recipient_email = appreciation.get_recipient_email()
|
||||||
recipient_phone = appreciation.get_recipient_phone()
|
recipient_phone = appreciation.get_recipient_phone()
|
||||||
@ -61,36 +66,22 @@ def send_appreciation_notification(appreciation):
|
|||||||
)
|
)
|
||||||
|
|
||||||
html_message = f"""
|
html_message = f"""
|
||||||
<!DOCTYPE html>
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
<html>
|
{get_email_header_html()}
|
||||||
<head><style>
|
<div style="padding: 30px;">
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
<h2 style="color: #005696; margin-top: 0;">🌟 Staff Appreciation</h2>
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
<div style="background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0;">
|
||||||
.header {{ background: linear-gradient(135deg, #005696 0%, #007bbd 100%); padding: 30px; text-align: center; color: white; }}
|
<strong>From:</strong> {sender_name}<br>
|
||||||
.content {{ padding: 30px; }}
|
<strong>Hospital:</strong> {appreciation.hospital.name}<br>
|
||||||
.appreciation-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0; }}
|
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
|
||||||
.personal-note {{ background: #fffbeb; border-left: 4px solid #f59e0b; padding: 20px; margin: 20px 0; }}
|
<br>
|
||||||
</style></head>
|
<strong>Message:</strong><br>
|
||||||
<body>
|
{appreciation.message_en}
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>🌟 Staff Appreciation</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="appreciation-box">
|
|
||||||
<strong>From:</strong> {sender_name}<br>
|
|
||||||
<strong>Hospital:</strong> {appreciation.hospital.name}<br>
|
|
||||||
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
|
|
||||||
<br>
|
|
||||||
<strong>Message:</strong><br>
|
|
||||||
{appreciation.message_en}
|
|
||||||
</div>
|
|
||||||
{f'<div class="personal-note"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''}
|
|
||||||
<p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{f'<div style="background: #fffbeb; border-left: 4px solid #f59e0b; padding: 20px; margin: 20px 0;"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''}
|
||||||
|
<p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
message_en = f"You've received an appreciation from {sender_name}!"
|
message_en = f"You've received an appreciation from {sender_name}!"
|
||||||
@ -116,8 +107,9 @@ def send_appreciation_notification(appreciation):
|
|||||||
html_message=html_message,
|
html_message=html_message,
|
||||||
related_object=appreciation,
|
related_object=appreciation,
|
||||||
)
|
)
|
||||||
|
notification_success = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send appreciation email: {e}")
|
logger.error(f"Failed to send appreciation email: {e}")
|
||||||
|
|
||||||
if recipient_phone:
|
if recipient_phone:
|
||||||
try:
|
try:
|
||||||
@ -126,24 +118,27 @@ def send_appreciation_notification(appreciation):
|
|||||||
message=message_en,
|
message=message_en,
|
||||||
related_object=appreciation,
|
related_object=appreciation,
|
||||||
)
|
)
|
||||||
|
notification_success = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send appreciation SMS: {e}")
|
logger.error(f"Failed to send appreciation SMS: {e}")
|
||||||
|
|
||||||
_send_department_head_notification(appreciation, sender_name)
|
_send_department_head_notification(appreciation, sender_name)
|
||||||
_send_cc_notifications(appreciation, sender_name, html_message, message_en)
|
_send_cc_notifications(appreciation, sender_name, html_message, message_en)
|
||||||
|
notification_success = True
|
||||||
|
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"Notification service not available: {e}")
|
logger.warning(f"Notification service not available: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error sending appreciation notification: {e}")
|
logger.error(f"Error sending appreciation notification: {e}")
|
||||||
|
|
||||||
appreciation.notification_sent = True
|
if notification_success:
|
||||||
appreciation.notification_sent_at = timezone.now()
|
appreciation.notification_sent = True
|
||||||
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
|
appreciation.notification_sent_at = timezone.now()
|
||||||
|
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
|
||||||
|
|
||||||
|
|
||||||
def _send_department_head_notification(appreciation, sender_name):
|
def _send_department_head_notification(appreciation, sender_name):
|
||||||
from apps.notifications.services import send_email as notify_send_email
|
from apps.notifications.services import send_email as notify_send_email, get_email_header_html
|
||||||
from apps.organizations.models import Staff
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
if not appreciation.department:
|
if not appreciation.department:
|
||||||
@ -168,35 +163,22 @@ def _send_department_head_notification(appreciation, sender_name):
|
|||||||
recipient_name = appreciation.get_recipient_name() if appreciation.recipient else "N/A"
|
recipient_name = appreciation.get_recipient_name() if appreciation.recipient else "N/A"
|
||||||
|
|
||||||
html = f"""
|
html = f"""
|
||||||
<!DOCTYPE html>
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
<html>
|
{get_email_header_html()}
|
||||||
<head><style>
|
<div style="padding: 30px;">
|
||||||
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
|
<h2 style="color: #005696; margin-top: 0;">🌟 New Appreciation in Your Department</h2>
|
||||||
.container {{ max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }}
|
<div style="background: #ecfdf5; border-left: 4px solid #10b981; padding: 20px; margin: 20px 0;">
|
||||||
.header {{ background: linear-gradient(135deg, #10b981 0%, #059669 100%); padding: 30px; text-align: center; color: white; }}
|
<strong>Staff Member:</strong> {recipient_name}<br>
|
||||||
.content {{ padding: 30px; }}
|
<strong>Department:</strong> {dept.name_en or dept.name}<br>
|
||||||
.info-box {{ background: #ecfdf5; border-left: 4px solid #10b981; padding: 20px; margin: 20px 0; }}
|
<strong>From:</strong> {sender_name}<br>
|
||||||
</style></head>
|
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
|
||||||
<body>
|
<br>
|
||||||
<div class="container">
|
<strong>Message:</strong><br>
|
||||||
<div class="header">
|
{appreciation.message_en}
|
||||||
<h1>🌟 New Appreciation in Your Department</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<div class="info-box">
|
|
||||||
<strong>Staff Member:</strong> {recipient_name}<br>
|
|
||||||
<strong>Department:</strong> {dept.name_en or dept.name}<br>
|
|
||||||
<strong>From:</strong> {sender_name}<br>
|
|
||||||
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
|
|
||||||
<br>
|
|
||||||
<strong>Message:</strong><br>
|
|
||||||
{appreciation.message_en}
|
|
||||||
</div>
|
|
||||||
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</div>
|
||||||
</html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
plain_message = (
|
plain_message = (
|
||||||
@ -215,7 +197,7 @@ def _send_department_head_notification(appreciation, sender_name):
|
|||||||
related_object=appreciation,
|
related_object=appreciation,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send department head notification: {e}")
|
logger.error(f"Failed to send department head notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _send_cc_notifications(appreciation, sender_name, html_message, plain_message):
|
def _send_cc_notifications(appreciation, sender_name, html_message, plain_message):
|
||||||
@ -239,7 +221,7 @@ def _send_cc_notifications(appreciation, sender_name, html_message, plain_messag
|
|||||||
related_object=appreciation,
|
related_object=appreciation,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to send CC notification to {cc_email}: {e}")
|
logger.error(f"Failed to send CC notification to {cc_email}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def update_appreciation_stats(instance):
|
def update_appreciation_stats(instance):
|
||||||
@ -248,12 +230,12 @@ def update_appreciation_stats(instance):
|
|||||||
|
|
||||||
Creates or updates monthly statistics.
|
Creates or updates monthly statistics.
|
||||||
"""
|
"""
|
||||||
# Get current year and month
|
from django.db.models import F
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
year = now.year
|
year = now.year
|
||||||
month = now.month
|
month = now.month
|
||||||
|
|
||||||
# Get or create stats record
|
|
||||||
stats, created = AppreciationStats.objects.get_or_create(
|
stats, created = AppreciationStats.objects.get_or_create(
|
||||||
recipient_content_type=instance.recipient_content_type,
|
recipient_content_type=instance.recipient_content_type,
|
||||||
recipient_object_id=instance.recipient_object_id,
|
recipient_object_id=instance.recipient_object_id,
|
||||||
@ -269,20 +251,18 @@ def update_appreciation_stats(instance):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update received count
|
AppreciationStats.objects.filter(pk=stats.pk).update(
|
||||||
stats.received_count += 1
|
received_count=F('received_count') + 1,
|
||||||
|
sent_count=F('sent_count') + 1,
|
||||||
|
)
|
||||||
|
stats.refresh_from_db()
|
||||||
|
|
||||||
# Update category breakdown
|
|
||||||
if instance.category:
|
if instance.category:
|
||||||
category_breakdown = stats.category_breakdown or {}
|
category_breakdown = stats.category_breakdown or {}
|
||||||
category_id_str = str(instance.category.id)
|
category_id_str = str(instance.category.id)
|
||||||
category_breakdown[category_id_str] = category_breakdown.get(category_id_str, 0) + 1
|
category_breakdown[category_id_str] = category_breakdown.get(category_id_str, 0) + 1
|
||||||
stats.category_breakdown = category_breakdown
|
AppreciationStats.objects.filter(pk=stats.pk).update(category_breakdown=category_breakdown)
|
||||||
|
|
||||||
# Save stats
|
|
||||||
stats.save()
|
|
||||||
|
|
||||||
# Recalculate rankings
|
|
||||||
recalculate_rankings(instance.hospital, year, month, instance.department)
|
recalculate_rankings(instance.hospital, year, month, instance.department)
|
||||||
|
|
||||||
|
|
||||||
@ -374,6 +354,31 @@ def check_and_award_badges(instance):
|
|||||||
appreciation_count=count,
|
appreciation_count=count,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify the user
|
||||||
|
try:
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.filter(pk=recipient_object_id).first()
|
||||||
|
if user and user.email:
|
||||||
|
from apps.notifications.services import NotificationService, get_email_header_html
|
||||||
|
NotificationService.send_email(
|
||||||
|
email=user.email,
|
||||||
|
subject=f"You earned a badge: {badge.name_en}!",
|
||||||
|
message=f"Congratulations! You've earned the \"{badge.name_en}\" badge for receiving {count} appreciations.",
|
||||||
|
html_message=f"""
|
||||||
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||||
|
{get_email_header_html()}
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Congratulations!</h2>
|
||||||
|
<p style="margin: 0 0 12px 0;">You've earned the <strong>{badge.name_en}</strong> badge for receiving <strong>{count}</strong> appreciations.</p>
|
||||||
|
<p style="margin: 0; color: #6b7280;">Keep up the great work!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send badge award notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
def check_badge_criteria(badge, content_type, object_id, hospital):
|
def check_badge_criteria(badge, content_type, object_id, hospital):
|
||||||
"""
|
"""
|
||||||
@ -444,26 +449,80 @@ def get_appreciation_count(content_type, object_id, criteria_type):
|
|||||||
sent_at__year=now.year,
|
sent_at__year=now.year,
|
||||||
sent_at__month=now.month,
|
sent_at__month=now.month,
|
||||||
).count()
|
).count()
|
||||||
|
elif criteria_type == 'diverse_senders':
|
||||||
|
return Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
).values('sender').distinct().count()
|
||||||
|
elif criteria_type == 'streak_weeks':
|
||||||
|
return check_appreciation_streak_count(content_type, object_id)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def check_appreciation_streak_count(content_type, object_id):
|
||||||
|
"""Count consecutive weeks of appreciation ending at current week."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
streak = 0
|
||||||
|
|
||||||
|
current_week_start = now - timedelta(days=now.weekday())
|
||||||
|
current_week_start = current_week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
if Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__gte=current_week_start,
|
||||||
|
sent_at__lte=now,
|
||||||
|
).exists():
|
||||||
|
streak += 1
|
||||||
|
|
||||||
|
for i in range(1, 52):
|
||||||
|
week_start = current_week_start - timedelta(weeks=i)
|
||||||
|
week_end = current_week_start - timedelta(weeks=i - 1)
|
||||||
|
if Appreciation.objects.filter(
|
||||||
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__gte=week_start,
|
||||||
|
sent_at__lt=week_end,
|
||||||
|
).exists():
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return streak
|
||||||
|
|
||||||
|
|
||||||
def check_appreciation_streak(content_type, object_id, required_weeks):
|
def check_appreciation_streak(content_type, object_id, required_weeks):
|
||||||
"""
|
"""
|
||||||
Check if recipient has appreciation streak for required weeks.
|
Check if recipient has appreciation streak for required weeks.
|
||||||
|
|
||||||
Returns True if streak meets or exceeds required_weeks.
|
Returns True if streak meets or exceeds required_weeks.
|
||||||
|
Includes the current partial week.
|
||||||
"""
|
"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
current_week = 0
|
current_week_start = now - timedelta(days=now.weekday())
|
||||||
|
current_week_start = current_week_start.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
# Check week by week going backwards
|
streak = 0
|
||||||
for i in range(required_weeks):
|
|
||||||
week_start = now - timedelta(weeks=i+1)
|
if Appreciation.objects.filter(
|
||||||
week_end = now - timedelta(weeks=i)
|
recipient_content_type=content_type,
|
||||||
|
recipient_object_id=object_id,
|
||||||
|
status=AppreciationStatus.SENT,
|
||||||
|
sent_at__gte=current_week_start,
|
||||||
|
sent_at__lte=now,
|
||||||
|
).exists():
|
||||||
|
streak += 1
|
||||||
|
|
||||||
|
for i in range(1, required_weeks):
|
||||||
|
week_start = current_week_start - timedelta(weeks=i)
|
||||||
|
week_end = current_week_start - timedelta(weeks=i - 1)
|
||||||
|
|
||||||
# Check if there's any appreciation in this week
|
|
||||||
has_appreciation = Appreciation.objects.filter(
|
has_appreciation = Appreciation.objects.filter(
|
||||||
recipient_content_type=content_type,
|
recipient_content_type=content_type,
|
||||||
recipient_object_id=object_id,
|
recipient_object_id=object_id,
|
||||||
@ -473,8 +532,8 @@ def check_appreciation_streak(content_type, object_id, required_weeks):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
if has_appreciation:
|
if has_appreciation:
|
||||||
current_week += 1
|
streak += 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
return current_week >= required_weeks
|
return streak >= required_weeks
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from django.contrib import messages
|
|||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db.models import Q, Count
|
from django.db.models import Q, Count
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
@ -113,6 +114,10 @@ def appreciation_detail(request, pk):
|
|||||||
|
|
||||||
categories = AppreciationCategory.objects.filter(is_active=True).order_by("order", "name_en")
|
categories = AppreciationCategory.objects.filter(is_active=True).order_by("order", "name_en")
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
appreciation_ct = ContentType.objects.get_for_model(appreciation)
|
||||||
|
generic_notes = appreciation.notes.select_related("created_by").all()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"appreciation": appreciation,
|
"appreciation": appreciation,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
@ -122,6 +127,10 @@ def appreciation_detail(request, pk):
|
|||||||
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
|
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
|
||||||
"can_send": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
|
"can_send": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
|
||||||
"is_recipient": False,
|
"is_recipient": False,
|
||||||
|
"content_type_id": appreciation_ct.pk,
|
||||||
|
"object_id": appreciation.pk,
|
||||||
|
"notes": generic_notes,
|
||||||
|
"notes_count": generic_notes.count(),
|
||||||
}
|
}
|
||||||
return render(request, "appreciation/appreciation_detail.html", context)
|
return render(request, "appreciation/appreciation_detail.html", context)
|
||||||
|
|
||||||
@ -191,29 +200,21 @@ def appreciation_activate(request, pk):
|
|||||||
from apps.core.ai_service import AIService
|
from apps.core.ai_service import AIService
|
||||||
|
|
||||||
analysis_result = AIService.chat_completion(
|
analysis_result = AIService.chat_completion(
|
||||||
messages=[
|
prompt=f'Analyze this patient appreciation message and provide:\n'
|
||||||
{
|
f'1. A summary of what the patient appreciated (in English and Arabic)\n'
|
||||||
"role": "system",
|
f'2. Key themes mentioned\n'
|
||||||
"content": "You are analyzing patient appreciation messages. Always respond with valid JSON.",
|
f'3. Suggested category if not already set\n'
|
||||||
},
|
f'4. Tone analysis\n\n'
|
||||||
{
|
f'Message: "{appreciation.message_en}"\n\n'
|
||||||
"role": "user",
|
f'Respond in JSON format with keys:\n'
|
||||||
"content": f'Analyze this patient appreciation message and provide:\n'
|
f'- summary_en: English summary\n'
|
||||||
f'1. A summary of what the patient appreciated (in English and Arabic)\n'
|
f'- summary_ar: Arabic summary\n'
|
||||||
f'2. Key themes mentioned\n'
|
f'- themes: List of key themes\n'
|
||||||
f'3. Suggested category if not already set\n'
|
f'- tone: "warm", "formal", or "casual"\n'
|
||||||
f'4. Tone analysis\n\n'
|
f'- suggested_response_en: Suggested response in English\n'
|
||||||
f'Message: "{appreciation.message_en}"\n\n'
|
f'- suggested_response_ar: Suggested response in Arabic',
|
||||||
f'Respond in JSON format with keys:\n'
|
system_prompt="You are analyzing patient appreciation messages. Always respond with valid JSON.",
|
||||||
f'- summary_en: English summary\n'
|
response_format="json_object",
|
||||||
f'- summary_ar: Arabic summary\n'
|
|
||||||
f'- themes: List of key themes\n'
|
|
||||||
f'- tone: "warm", "formal", or "casual"\n'
|
|
||||||
f'- suggested_response_en: Suggested response in English\n'
|
|
||||||
f'- suggested_response_ar: Suggested response in Arabic',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response_format={"type": "json_object"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@ -224,11 +225,8 @@ def appreciation_activate(request, pk):
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).error(f"AI analysis failed for appreciation {appreciation.pk}: {str(e)}")
|
logging.getLogger(__name__).error(f"AI analysis failed for appreciation {appreciation.pk}: {str(e)}")
|
||||||
appreciation.status = AppreciationStatus.AI_ANALYZED
|
|
||||||
appreciation.ai_analyzed_at = timezone.now()
|
|
||||||
appreciation.save(update_fields=["status", "ai_analyzed_at"])
|
|
||||||
|
|
||||||
messages.success(request, _("Appreciation activated and AI analysis complete."))
|
messages.success(request, _("Appreciation activated successfully."))
|
||||||
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
|
||||||
|
|
||||||
|
|
||||||
@ -304,6 +302,10 @@ def appreciation_acknowledge(request, pk):
|
|||||||
messages.error(request, "You can only acknowledge appreciations sent to you.")
|
messages.error(request, "You can only acknowledge appreciations sent to you.")
|
||||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||||
|
|
||||||
|
if appreciation.status != AppreciationStatus.SENT:
|
||||||
|
messages.error(request, "This appreciation cannot be acknowledged in its current status.")
|
||||||
|
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||||
|
|
||||||
# Acknowledge
|
# Acknowledge
|
||||||
appreciation.acknowledge()
|
appreciation.acknowledge()
|
||||||
|
|
||||||
@ -420,8 +422,31 @@ def my_badges_view(request):
|
|||||||
for badge in available_badges:
|
for badge in available_badges:
|
||||||
earned = queryset.filter(badge=badge).exists()
|
earned = queryset.filter(badge=badge).exists()
|
||||||
progress = 0
|
progress = 0
|
||||||
if badge.criteria_type == 'count':
|
if badge.criteria_type in ('received_count', 'received_month'):
|
||||||
progress = min(100, int((total_received / badge.criteria_value) * 100))
|
progress = min(100, int((total_received / badge.criteria_value) * 100)) if badge.criteria_value else 0
|
||||||
|
elif badge.criteria_type == 'diverse_senders':
|
||||||
|
unique_senders = Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id
|
||||||
|
).values('sender').distinct().count()
|
||||||
|
progress = min(100, int((unique_senders / badge.criteria_value) * 100)) if badge.criteria_value else 0
|
||||||
|
elif badge.criteria_type == 'streak_weeks':
|
||||||
|
from datetime import timedelta
|
||||||
|
now = timezone.now()
|
||||||
|
streak = 0
|
||||||
|
for i in range(badge.criteria_value):
|
||||||
|
week_start = now - timedelta(weeks=i+1)
|
||||||
|
week_end = now - timedelta(weeks=i)
|
||||||
|
if Appreciation.objects.filter(
|
||||||
|
recipient_content_type=user_content_type,
|
||||||
|
recipient_object_id=user.id,
|
||||||
|
sent_at__gte=week_start,
|
||||||
|
sent_at__lt=week_end,
|
||||||
|
).exists():
|
||||||
|
streak += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
progress = min(100, int((streak / badge.criteria_value) * 100)) if badge.criteria_value else 0
|
||||||
|
|
||||||
badge_progress.append({
|
badge_progress.append({
|
||||||
'badge': badge,
|
'badge': badge,
|
||||||
@ -692,7 +717,7 @@ def badge_create(request):
|
|||||||
description_ar = request.POST.get('description_ar', '')
|
description_ar = request.POST.get('description_ar', '')
|
||||||
icon = request.POST.get('icon', 'fa-award')
|
icon = request.POST.get('icon', 'fa-award')
|
||||||
color = request.POST.get('color', '#FFD700')
|
color = request.POST.get('color', '#FFD700')
|
||||||
criteria_type = request.POST.get('criteria_type', 'count')
|
criteria_type = request.POST.get('criteria_type', 'received_count')
|
||||||
criteria_value = request.POST.get('criteria_value', 5)
|
criteria_value = request.POST.get('criteria_value', 5)
|
||||||
order = request.POST.get('order', 0)
|
order = request.POST.get('order', 0)
|
||||||
is_active = request.POST.get('is_active') == 'on'
|
is_active = request.POST.get('is_active') == 'on'
|
||||||
@ -761,7 +786,7 @@ def badge_edit(request, pk):
|
|||||||
badge.description_ar = request.POST.get('description_ar', '')
|
badge.description_ar = request.POST.get('description_ar', '')
|
||||||
badge.icon = request.POST.get('icon', 'fa-award')
|
badge.icon = request.POST.get('icon', 'fa-award')
|
||||||
badge.color = request.POST.get('color', '#FFD700')
|
badge.color = request.POST.get('color', '#FFD700')
|
||||||
badge.criteria_type = request.POST.get('criteria_type', 'count')
|
badge.criteria_type = request.POST.get('criteria_type', 'received_count')
|
||||||
badge.criteria_value = request.POST.get('criteria_value', 5)
|
badge.criteria_value = request.POST.get('criteria_value', 5)
|
||||||
badge.order = request.POST.get('order', 0)
|
badge.order = request.POST.get('order', 0)
|
||||||
badge.is_active = request.POST.get('is_active') == 'on'
|
badge.is_active = request.POST.get('is_active') == 'on'
|
||||||
@ -980,6 +1005,13 @@ def public_appreciation_submit(request):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
client_ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR', '')
|
||||||
|
cache_key = f"appreciation_rate:{client_ip}"
|
||||||
|
from django.core.cache import cache
|
||||||
|
if cache.get(cache_key, 0) >= 5:
|
||||||
|
return JsonResponse({"success": False, "message": "Too many requests. Please try again later."}, status=429)
|
||||||
|
cache.set(cache_key, cache.get(cache_key, 0) + 1, 300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body) if request.content_type == 'application/json' else request.POST
|
data = json.loads(request.body) if request.content_type == 'application/json' else request.POST
|
||||||
@ -991,9 +1023,8 @@ def public_appreciation_submit(request):
|
|||||||
message = data.get("message", "").strip()
|
message = data.get("message", "").strip()
|
||||||
hospital_id = data.get("hospital", "")
|
hospital_id = data.get("hospital", "")
|
||||||
staff_name = data.get("staff_name", "").strip()
|
staff_name = data.get("staff_name", "").strip()
|
||||||
location_id = data.get("location", "").strip()
|
department_id = data.get("department", "").strip()
|
||||||
main_section_id = data.get("main_section", "").strip()
|
section_id = data.get("section", "").strip()
|
||||||
subsection_id = data.get("subsection", "").strip()
|
|
||||||
|
|
||||||
if not contact_name or not contact_phone or not message:
|
if not contact_name or not contact_phone or not message:
|
||||||
return JsonResponse({"success": False, "message": "Name, phone, and message are required."}, status=400)
|
return JsonResponse({"success": False, "message": "Name, phone, and message are required."}, status=400)
|
||||||
@ -1006,16 +1037,14 @@ def public_appreciation_submit(request):
|
|||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
return JsonResponse({"success": False, "message": "Invalid hospital."}, status=400)
|
return JsonResponse({"success": False, "message": "Invalid hospital."}, status=400)
|
||||||
|
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
from apps.organizations.models import Department, Section, OrgSubSection
|
||||||
location = Location.objects.filter(id=location_id).first() if location_id else None
|
department = Department.objects.filter(id=department_id).first() if department_id else None
|
||||||
main_section = MainSection.objects.filter(id=main_section_id).first() if main_section_id else None
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
||||||
subsection = SubSection.objects.filter(id=subsection_id).first() if subsection_id else None
|
|
||||||
|
|
||||||
appreciation = Appreciation(
|
appreciation = Appreciation(
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
location=location,
|
department=department,
|
||||||
main_section=main_section,
|
section=section,
|
||||||
subsection=subsection,
|
|
||||||
category=None,
|
category=None,
|
||||||
message_en=message,
|
message_en=message,
|
||||||
message_ar="",
|
message_ar="",
|
||||||
@ -1044,7 +1073,7 @@ def public_appreciation_submit(request):
|
|||||||
metadata={"hospital": str(hospital.id), "staff_mentioned": staff_name},
|
metadata={"hospital": str(hospital.id), "staff_mentioned": staff_name},
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse({"success": True, "reference": f"APR-{str(appreciation.pk)[:8]}"})
|
return JsonResponse({"success": True, "reference": appreciation.reference_number})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("ERROR in public_appreciation_submit")
|
logger.exception("ERROR in public_appreciation_submit")
|
||||||
return JsonResponse({"success": False, "message": str(e)}, status=500)
|
return JsonResponse({"success": False, "message": str(e)}, status=500)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ from .models import (
|
|||||||
ComplaintAttachment,
|
ComplaintAttachment,
|
||||||
ComplaintCategory,
|
ComplaintCategory,
|
||||||
ComplaintMeeting,
|
ComplaintMeeting,
|
||||||
|
ComplaintPdfSummary,
|
||||||
ComplaintPRInteraction,
|
ComplaintPRInteraction,
|
||||||
ComplaintSLAConfig,
|
ComplaintSLAConfig,
|
||||||
ComplaintThreshold,
|
ComplaintThreshold,
|
||||||
@ -134,9 +135,11 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
"priority",
|
"priority",
|
||||||
"category",
|
"category",
|
||||||
"source",
|
"source",
|
||||||
"location",
|
"legacy_location",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
|
"section",
|
||||||
|
|
||||||
"is_overdue",
|
"is_overdue",
|
||||||
"hospital",
|
"hospital",
|
||||||
"created_by",
|
"created_by",
|
||||||
@ -170,7 +173,7 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
{"fields": ("patient", "patient_name", "file_number", "encounter_id", "incident_date", "contact_phone")},
|
{"fields": ("patient", "patient_name", "file_number", "encounter_id", "incident_date", "contact_phone")},
|
||||||
),
|
),
|
||||||
("Organization", {"fields": ("hospital", "department", "staff")}),
|
("Organization", {"fields": ("hospital", "department", "staff")}),
|
||||||
("Location Hierarchy", {"fields": ("location", "main_section", "subsection")}),
|
("Location Hierarchy", {"fields": ("legacy_location", "legacy_main_section", "legacy_subsection", "section")}),
|
||||||
(
|
(
|
||||||
"Complaint Details",
|
"Complaint Details",
|
||||||
{
|
{
|
||||||
@ -256,9 +259,11 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
"hospital",
|
"hospital",
|
||||||
"department",
|
"department",
|
||||||
"staff",
|
"staff",
|
||||||
"location",
|
"legacy_location",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
|
"section",
|
||||||
|
|
||||||
"assigned_to",
|
"assigned_to",
|
||||||
"resolved_by",
|
"resolved_by",
|
||||||
"closed_by",
|
"closed_by",
|
||||||
@ -275,12 +280,12 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
def location_hierarchy(self, obj):
|
def location_hierarchy(self, obj):
|
||||||
"""Display location hierarchy in admin"""
|
"""Display location hierarchy in admin"""
|
||||||
parts = []
|
parts = []
|
||||||
if obj.location:
|
if obj.legacy_location:
|
||||||
parts.append(obj.location.name_en or obj.location.name_ar or str(obj.location))
|
parts.append(obj.legacy_location.name_en or obj.legacy_location.name_ar or str(obj.legacy_location))
|
||||||
if obj.main_section:
|
if obj.legacy_main_section:
|
||||||
parts.append(obj.main_section.name_en or obj.main_section.name_ar or str(obj.main_section))
|
parts.append(obj.legacy_main_section.name_en or obj.legacy_main_section.name_ar or str(obj.legacy_main_section))
|
||||||
if obj.subsection:
|
if obj.legacy_subsection:
|
||||||
parts.append(obj.subsection.name_en or obj.subsection.name_ar or str(obj.subsection))
|
parts.append(obj.legacy_subsection.name_en or obj.legacy_subsection.name_ar or str(obj.legacy_subsection))
|
||||||
|
|
||||||
if not parts:
|
if not parts:
|
||||||
return "—"
|
return "—"
|
||||||
@ -312,8 +317,8 @@ class ComplaintAdmin(admin.ModelAdmin):
|
|||||||
"resolved": "success",
|
"resolved": "success",
|
||||||
"closed": "secondary",
|
"closed": "secondary",
|
||||||
"cancelled": "secondary",
|
"cancelled": "secondary",
|
||||||
"contacted": "info",
|
"pending_external": "info",
|
||||||
"contacted_no_response": "danger",
|
"ovr_pending": "warning",
|
||||||
}
|
}
|
||||||
color = colors.get(obj.status, "secondary")
|
color = colors.get(obj.status, "secondary")
|
||||||
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_status_display())
|
return format_html('<span class="badge bg-{0}">{1}</span>', color, obj.get_status_display())
|
||||||
@ -387,6 +392,21 @@ class ComplaintAttachmentAdmin(admin.ModelAdmin):
|
|||||||
return qs.select_related("complaint", "uploaded_by")
|
return qs.select_related("complaint", "uploaded_by")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ComplaintPdfSummary)
|
||||||
|
class ComplaintPdfSummaryAdmin(admin.ModelAdmin):
|
||||||
|
"""PDF summary admin"""
|
||||||
|
|
||||||
|
list_display = ["complaint", "file_size", "created_at"]
|
||||||
|
search_fields = ["complaint__reference_number"]
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
readonly_fields = ["file_size", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
return qs.select_related("complaint")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(ComplaintUpdate)
|
@admin.register(ComplaintUpdate)
|
||||||
class ComplaintUpdateAdmin(admin.ModelAdmin):
|
class ComplaintUpdateAdmin(admin.ModelAdmin):
|
||||||
"""Complaint update admin"""
|
"""Complaint update admin"""
|
||||||
@ -1008,7 +1028,7 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
|
|||||||
"ticket_number",
|
"ticket_number",
|
||||||
"source",
|
"source",
|
||||||
"complainant_name",
|
"complainant_name",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"received_date",
|
"received_date",
|
||||||
"status",
|
"status",
|
||||||
"converted_to_complaint",
|
"converted_to_complaint",
|
||||||
@ -1033,7 +1053,7 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Source", {"fields": ("source", "ticket_number")}),
|
("Source", {"fields": ("source", "ticket_number")}),
|
||||||
("Complainant", {"fields": ("complainant_name", "national_id", "contact_number")}),
|
("Complainant", {"fields": ("complainant_name", "national_id", "contact_number")}),
|
||||||
("Location & Section", {"fields": ("location", "main_section", "subsection")}),
|
("Location & Section", {"fields": ("legacy_location", "legacy_main_section", "legacy_subsection", "section")}),
|
||||||
("Dates", {"fields": ("received_date",)}),
|
("Dates", {"fields": ("received_date",)}),
|
||||||
("Content", {"fields": ("classification", "content")}),
|
("Content", {"fields": ("classification", "content")}),
|
||||||
("Status & Assignment", {"fields": ("status", "assigned_to")}),
|
("Status & Assignment", {"fields": ("status", "assigned_to")}),
|
||||||
@ -1045,4 +1065,4 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
qs = super().get_queryset(request)
|
qs = super().get_queryset(request)
|
||||||
return qs.select_related("source", "location", "main_section", "assigned_to", "complaint")
|
return qs.select_related("source", "legacy_location", "legacy_main_section", "section", "assigned_to", "complaint")
|
||||||
|
|||||||
@ -27,7 +27,7 @@ from apps.complaints.models import (
|
|||||||
)
|
)
|
||||||
from apps.core.models import PriorityChoices, SeverityChoices
|
from apps.core.models import PriorityChoices, SeverityChoices
|
||||||
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
|
from apps.core.form_mixins import HospitalFieldMixin, DepartmentFieldMixin
|
||||||
from apps.organizations.models import Department, Hospital, Patient, Staff
|
from apps.organizations.models import Area, Department, Hospital, LocationType, Patient, Staff, Section
|
||||||
|
|
||||||
|
|
||||||
class MultiFileInput(forms.FileInput):
|
class MultiFileInput(forms.FileInput):
|
||||||
@ -137,31 +137,28 @@ class PublicComplaintForm(forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}),
|
widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Complaint Details - Location Hierarchy
|
# Location Type and Area
|
||||||
location = forms.ModelChoiceField(
|
location_type = forms.ChoiceField(
|
||||||
label=_("Location"),
|
label=_("Location Type"),
|
||||||
queryset=None,
|
choices=[("", _("Select Location Type"))] + list(LocationType.choices),
|
||||||
empty_label=_("Select Location"),
|
|
||||||
required=True,
|
required=True,
|
||||||
widget=forms.Select(attrs={"class": "form-control", "id": "location_select", "data-action": "load-sections"}),
|
widget=forms.Select(attrs={"class": "form-control", "id": "location_type_select"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
main_section = forms.ModelChoiceField(
|
area = forms.ModelChoiceField(
|
||||||
|
label=_("Area (Optional)"),
|
||||||
|
queryset=Area.objects.none(),
|
||||||
|
empty_label=_("Select Area"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-control", "id": "area_select"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
section = forms.ModelChoiceField(
|
||||||
label=_("Section"),
|
label=_("Section"),
|
||||||
queryset=None,
|
queryset=Section.objects.none(),
|
||||||
empty_label=_("Select Section"),
|
empty_label=_("Select Section"),
|
||||||
required=True,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(attrs={"class": "form-control", "id": "section_select"}),
|
||||||
attrs={"class": "form-control", "id": "main_section_select", "data-action": "load-subsections"}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
subsection = forms.ModelChoiceField(
|
|
||||||
label=_("Subsection"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Subsection"),
|
|
||||||
required=True,
|
|
||||||
widget=forms.Select(attrs={"class": "form-control", "id": "subsection_select"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
staff_name = forms.CharField(
|
staff_name = forms.CharField(
|
||||||
@ -212,11 +209,11 @@ class PublicComplaintForm(forms.ModelForm):
|
|||||||
widget=forms.HiddenInput(),
|
widget=forms.HiddenInput(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Source type - always external for public complaints
|
# Source type - always internal for public complaints
|
||||||
complaint_source_type = forms.ChoiceField(
|
complaint_source_type = forms.ChoiceField(
|
||||||
label=_("Complaint Source Type"),
|
label=_("Complaint Source Type"),
|
||||||
choices=ComplaintSourceType.choices,
|
choices=ComplaintSourceType.choices,
|
||||||
initial=ComplaintSourceType.EXTERNAL,
|
initial=ComplaintSourceType.INTERNAL,
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.HiddenInput(),
|
widget=forms.HiddenInput(),
|
||||||
)
|
)
|
||||||
@ -240,9 +237,10 @@ class PublicComplaintForm(forms.ModelForm):
|
|||||||
"patient_name",
|
"patient_name",
|
||||||
"national_id",
|
"national_id",
|
||||||
"incident_date",
|
"incident_date",
|
||||||
"location",
|
"location_type",
|
||||||
"main_section",
|
"area",
|
||||||
"subsection",
|
"department",
|
||||||
|
"section",
|
||||||
"staff_name",
|
"staff_name",
|
||||||
"complaint_details",
|
"complaint_details",
|
||||||
"expected_result",
|
"expected_result",
|
||||||
@ -255,46 +253,10 @@ class PublicComplaintForm(forms.ModelForm):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
|
||||||
|
|
||||||
# Initialize cascading dropdowns with empty querysets
|
self.fields["section"].queryset = Section.objects.none()
|
||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
self.fields["area"].queryset = Area.objects.none()
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
|
||||||
|
|
||||||
# Load all locations (no filtering needed)
|
|
||||||
self.fields["location"].queryset = Location.active_locations()
|
|
||||||
|
|
||||||
# Check both initial data and POST data for location to load sections
|
|
||||||
location_id = None
|
|
||||||
if "location" in self.initial:
|
|
||||||
location_id = self.initial["location"]
|
|
||||||
elif "location" in self.data:
|
|
||||||
location_id = self.data["location"]
|
|
||||||
|
|
||||||
if location_id:
|
|
||||||
# Filter sections based on selected location
|
|
||||||
from apps.organizations.models import SubSection
|
|
||||||
|
|
||||||
available_sections = (
|
|
||||||
SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
|
|
||||||
)
|
|
||||||
self.fields["main_section"].queryset = MainSection.objects.filter(id__in=available_sections).order_by(
|
|
||||||
"name_en"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load subsections if section is selected
|
|
||||||
section_id = None
|
|
||||||
if "main_section" in self.initial:
|
|
||||||
section_id = self.initial["main_section"]
|
|
||||||
elif "main_section" in self.data:
|
|
||||||
section_id = self.data["main_section"]
|
|
||||||
|
|
||||||
if section_id:
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.filter(
|
|
||||||
location_id=location_id, main_section_id=section_id
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
# Also filter departments based on hospital if provided
|
|
||||||
hospital_id = None
|
hospital_id = None
|
||||||
if "hospital" in self.initial:
|
if "hospital" in self.initial:
|
||||||
hospital_id = self.initial["hospital"]
|
hospital_id = self.initial["hospital"]
|
||||||
@ -302,10 +264,23 @@ class PublicComplaintForm(forms.ModelForm):
|
|||||||
hospital_id = self.data["hospital"]
|
hospital_id = self.data["hospital"]
|
||||||
|
|
||||||
if hospital_id:
|
if hospital_id:
|
||||||
# Filter departments
|
|
||||||
self.fields["department"].queryset = Department.objects.filter(
|
self.fields["department"].queryset = Department.objects.filter(
|
||||||
hospital_id=hospital_id, status="active"
|
hospital_id=hospital_id, status="active"
|
||||||
).order_by("name")
|
).order_by("name")
|
||||||
|
self.fields["area"].queryset = Area.objects.filter(
|
||||||
|
hospital_id=hospital_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
department_id = None
|
||||||
|
if "department" in self.initial:
|
||||||
|
department_id = self.initial["department"]
|
||||||
|
elif "department" in self.data:
|
||||||
|
department_id = self.data["department"]
|
||||||
|
|
||||||
|
if department_id:
|
||||||
|
self.fields["section"].queryset = Section.objects.filter(
|
||||||
|
department_id=department_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
def clean_mobile_number(self):
|
def clean_mobile_number(self):
|
||||||
"""Validate mobile number format"""
|
"""Validate mobile number format"""
|
||||||
@ -380,9 +355,9 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
"""
|
"""
|
||||||
Form for creating complaints by authenticated users.
|
Form for creating complaints by authenticated users.
|
||||||
|
|
||||||
Updated to use location hierarchy (Location, Section, Subsection).
|
Uses Category → Department → Section hierarchy.
|
||||||
Includes new fields for detailed patient information and complaint type.
|
Includes new fields for detailed patient information and complaint type.
|
||||||
Uses cascading dropdowns for location selection.
|
Uses cascading dropdowns for department/section selection.
|
||||||
|
|
||||||
Hospital field visibility:
|
Hospital field visibility:
|
||||||
- PX Admins: See dropdown with all hospitals
|
- PX Admins: See dropdown with all hospitals
|
||||||
@ -402,7 +377,7 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
complaint_source_type = forms.ChoiceField(
|
complaint_source_type = forms.ChoiceField(
|
||||||
label=_("Complaint Source Type"),
|
label=_("Complaint Source Type"),
|
||||||
choices=ComplaintSourceType.choices,
|
choices=ComplaintSourceType.choices,
|
||||||
initial=ComplaintSourceType.EXTERNAL,
|
initial=ComplaintSourceType.INTERNAL,
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(attrs={"class": "form-select", "id": "complaintSourceType"}),
|
widget=forms.Select(attrs={"class": "form-select", "id": "complaintSourceType"}),
|
||||||
)
|
)
|
||||||
@ -453,6 +428,21 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}),
|
widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
location_type = forms.ChoiceField(
|
||||||
|
label=_("Location Type"),
|
||||||
|
choices=[("", _("Select Location Type"))] + list(LocationType.choices),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={"class": "form-select", "id": "locationTypeSelect"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
area = forms.ModelChoiceField(
|
||||||
|
label=_("Area"),
|
||||||
|
queryset=Area.objects.none(),
|
||||||
|
empty_label=_("Select Area (optional)"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-select", "id": "areaSelect"}),
|
||||||
|
)
|
||||||
|
|
||||||
department = forms.ModelChoiceField(
|
department = forms.ModelChoiceField(
|
||||||
label=_("Department"),
|
label=_("Department"),
|
||||||
queryset=Department.objects.none(),
|
queryset=Department.objects.none(),
|
||||||
@ -461,6 +451,14 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"class": "form-select", "id": "departmentSelect"}),
|
widget=forms.Select(attrs={"class": "form-select", "id": "departmentSelect"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
section = forms.ModelChoiceField(
|
||||||
|
label=_("Section"),
|
||||||
|
queryset=Section.objects.none(),
|
||||||
|
empty_label=_("Select Section (optional)"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-select", "id": "sectionSelect"}),
|
||||||
|
)
|
||||||
|
|
||||||
staff = forms.ModelChoiceField(
|
staff = forms.ModelChoiceField(
|
||||||
label=_("Staff"),
|
label=_("Staff"),
|
||||||
queryset=Staff.objects.none(),
|
queryset=Staff.objects.none(),
|
||||||
@ -469,48 +467,6 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"class": "form-select", "id": "staffSelect"}),
|
widget=forms.Select(attrs={"class": "form-select", "id": "staffSelect"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
encounter_id = forms.CharField(
|
|
||||||
label=_("Encounter ID"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Optional encounter/visit ID")}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Location Hierarchy Fields
|
|
||||||
location = forms.ModelChoiceField(
|
|
||||||
label=_("Location"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Location"),
|
|
||||||
required=True,
|
|
||||||
widget=forms.Select(attrs={"class": "form-select", "id": "locationSelect", "data-action": "load-sections"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
main_section = forms.ModelChoiceField(
|
|
||||||
label=_("Section"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Section"),
|
|
||||||
required=True,
|
|
||||||
widget=forms.Select(
|
|
||||||
attrs={"class": "form-select", "id": "mainSectionSelect", "data-action": "load-subsections"}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
subsection = forms.ModelChoiceField(
|
|
||||||
label=_("Subsection"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Subsection"),
|
|
||||||
required=True,
|
|
||||||
widget=forms.Select(attrs={"class": "form-select", "id": "subsectionSelect"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
staff_name = forms.CharField(
|
|
||||||
label=_("Staff Involved"),
|
|
||||||
max_length=200,
|
|
||||||
required=False,
|
|
||||||
widget=forms.TextInput(
|
|
||||||
attrs={"class": "form-control", "placeholder": _("Name of staff member involved (if known)")}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
description = forms.CharField(
|
description = forms.CharField(
|
||||||
label=_("Description"),
|
label=_("Description"),
|
||||||
required=True,
|
required=True,
|
||||||
@ -537,14 +493,12 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
"patient_name",
|
"patient_name",
|
||||||
"national_id",
|
"national_id",
|
||||||
"incident_date",
|
"incident_date",
|
||||||
|
"location_type",
|
||||||
|
"area",
|
||||||
"hospital",
|
"hospital",
|
||||||
"department",
|
"department",
|
||||||
"location",
|
"section",
|
||||||
"main_section",
|
|
||||||
"subsection",
|
|
||||||
"staff",
|
"staff",
|
||||||
"staff_name",
|
|
||||||
"encounter_id",
|
|
||||||
"description",
|
"description",
|
||||||
"expected_result",
|
"expected_result",
|
||||||
]
|
]
|
||||||
@ -552,44 +506,16 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Note: user is handled by HospitalFieldMixin
|
# Note: user is handled by HospitalFieldMixin
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
from apps.organizations.models import Section
|
||||||
from apps.px_sources.models import PXSource
|
from apps.px_sources.models import PXSource
|
||||||
|
|
||||||
# Initialize cascading dropdowns with empty querysets
|
# Initialize cascading dropdowns with empty querysets
|
||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
self.fields["section"].queryset = Section.objects.none()
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
self.fields["area"].queryset = Area.objects.none()
|
||||||
|
|
||||||
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.request and self.request.user and hasattr(self.request.user, 'hospital') and self.request.user.hospital:
|
|
||||||
hospital_id = self.request.user.hospital.id
|
|
||||||
|
|
||||||
self.fields["location"].queryset = Location.active_locations()
|
|
||||||
|
|
||||||
# Load active PX sources for optional selection
|
# Load active PX sources for optional selection
|
||||||
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
||||||
|
|
||||||
# Populate cascading dropdown querysets based on submitted/initial data
|
|
||||||
location_id = self.data.get("location") or self.initial.get("location")
|
|
||||||
if location_id:
|
|
||||||
available_sections = (
|
|
||||||
SubSection.objects.filter(location_id=location_id)
|
|
||||||
.values_list("main_section_id", flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
self.fields["main_section"].queryset = MainSection.objects.filter(
|
|
||||||
id__in=available_sections
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
section_id = self.data.get("main_section") or self.initial.get("main_section")
|
|
||||||
if section_id:
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.filter(
|
|
||||||
location_id=location_id, main_section_id=section_id
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
# Hospital field is configured by HospitalFieldMixin
|
# Hospital field is configured by HospitalFieldMixin
|
||||||
# Now filter departments and staff based on hospital
|
# Now filter departments and staff based on hospital
|
||||||
hospital_id = None
|
hospital_id = None
|
||||||
@ -615,6 +541,18 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
"first_name", "last_name"
|
"first_name", "last_name"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Filter areas based on selected hospital
|
||||||
|
self.fields["area"].queryset = Area.objects.filter(
|
||||||
|
hospital_id=hospital_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
# Populate section dropdown based on selected department
|
||||||
|
department_id = self.data.get("department") or self.initial.get("department")
|
||||||
|
if department_id:
|
||||||
|
self.fields["section"].queryset = Section.objects.filter(
|
||||||
|
department_id=department_id
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
def clean_incident_date(self):
|
def clean_incident_date(self):
|
||||||
incident_date = self.cleaned_data.get("incident_date")
|
incident_date = self.cleaned_data.get("incident_date")
|
||||||
|
|
||||||
@ -654,6 +592,21 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}),
|
widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
location_type = forms.ChoiceField(
|
||||||
|
label=_("Location Type"),
|
||||||
|
choices=[("", _("Select Location Type"))] + list(LocationType.choices),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={"class": "form-select", "id": "locationTypeSelect"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
area = forms.ModelChoiceField(
|
||||||
|
label=_("Area"),
|
||||||
|
queryset=Area.objects.none(),
|
||||||
|
empty_label=_("Select Area (optional)"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-select", "id": "areaSelect"}),
|
||||||
|
)
|
||||||
|
|
||||||
department = forms.ModelChoiceField(
|
department = forms.ModelChoiceField(
|
||||||
label=_("Department (Optional)"),
|
label=_("Department (Optional)"),
|
||||||
queryset=Department.objects.none(),
|
queryset=Department.objects.none(),
|
||||||
@ -702,30 +655,12 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
label=_("Contact Email"), required=False, widget=forms.EmailInput(attrs={"class": "form-control"})
|
label=_("Contact Email"), required=False, widget=forms.EmailInput(attrs={"class": "form-control"})
|
||||||
)
|
)
|
||||||
|
|
||||||
location = forms.ModelChoiceField(
|
section = forms.ModelChoiceField(
|
||||||
label=_("Location"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Location"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.Select(attrs={"class": "form-select", "id": "locationSelect", "data-action": "load-sections"}),
|
|
||||||
)
|
|
||||||
|
|
||||||
main_section = forms.ModelChoiceField(
|
|
||||||
label=_("Section"),
|
label=_("Section"),
|
||||||
queryset=None,
|
queryset=None,
|
||||||
empty_label=_("Select Section"),
|
empty_label=_("Select Section"),
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(
|
widget=forms.Select(attrs={"class": "form-select", "id": "sectionSelect"}),
|
||||||
attrs={"class": "form-select", "id": "mainSectionSelect", "data-action": "load-subsections"}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
subsection = forms.ModelChoiceField(
|
|
||||||
label=_("Subsection"),
|
|
||||||
queryset=None,
|
|
||||||
empty_label=_("Select Subsection"),
|
|
||||||
required=False,
|
|
||||||
widget=forms.Select(attrs={"class": "form-select", "id": "subsectionSelect"}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
priority = forms.ChoiceField(
|
priority = forms.ChoiceField(
|
||||||
@ -771,15 +706,15 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
"patient",
|
"patient",
|
||||||
"hospital",
|
"hospital",
|
||||||
|
"location_type",
|
||||||
|
"area",
|
||||||
"department",
|
"department",
|
||||||
"subject",
|
"subject",
|
||||||
"message",
|
"message",
|
||||||
"contact_name",
|
"contact_name",
|
||||||
"contact_phone",
|
"contact_phone",
|
||||||
"contact_email",
|
"contact_email",
|
||||||
"location",
|
"section",
|
||||||
"main_section",
|
|
||||||
"subsection",
|
|
||||||
"priority",
|
"priority",
|
||||||
"source",
|
"source",
|
||||||
"is_outgoing",
|
"is_outgoing",
|
||||||
@ -790,35 +725,17 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
# Note: user is handled by HospitalFieldMixin
|
# Note: user is handled by HospitalFieldMixin
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
from apps.organizations.models import Section
|
||||||
from apps.px_sources.models import PXSource
|
from apps.px_sources.models import PXSource
|
||||||
|
|
||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
self.fields["section"].queryset = Section.objects.none()
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
self.fields["area"].queryset = Area.objects.none()
|
||||||
self.fields["location"].queryset = Location.objects.none()
|
|
||||||
|
|
||||||
# Load active PX sources for optional selection
|
# Load active PX sources for optional selection
|
||||||
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
self.fields["source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
|
||||||
self.fields["source"].empty_label = "Select source (optional)"
|
self.fields["source"].empty_label = "Select source (optional)"
|
||||||
self.fields["source"].required = False
|
self.fields["source"].required = False
|
||||||
|
|
||||||
location_id = self.data.get("location") or self.initial.get("location")
|
|
||||||
if location_id:
|
|
||||||
available_sections = (
|
|
||||||
SubSection.objects.filter(location_id=location_id)
|
|
||||||
.values_list("main_section_id", flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
self.fields["main_section"].queryset = MainSection.objects.filter(
|
|
||||||
id__in=available_sections
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
section_id = self.data.get("main_section") or self.initial.get("main_section")
|
|
||||||
if section_id:
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.filter(
|
|
||||||
location_id=location_id, main_section_id=section_id
|
|
||||||
).order_by("name_en")
|
|
||||||
|
|
||||||
hospital_id = None
|
hospital_id = None
|
||||||
if self.data.get("hospital"):
|
if self.data.get("hospital"):
|
||||||
hospital_id = self.data.get("hospital")
|
hospital_id = self.data.get("hospital")
|
||||||
@ -838,6 +755,16 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
|
|||||||
self.fields["outgoing_department"].queryset = Department.objects.filter(
|
self.fields["outgoing_department"].queryset = Department.objects.filter(
|
||||||
hospital_id=hospital_id, status="active"
|
hospital_id=hospital_id, status="active"
|
||||||
).order_by("name")
|
).order_by("name")
|
||||||
|
self.fields["area"].queryset = Area.objects.filter(
|
||||||
|
hospital_id=hospital_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
# Populate section dropdown based on selected department
|
||||||
|
department_id = self.data.get("department") or self.initial.get("department")
|
||||||
|
if department_id:
|
||||||
|
self.fields["section"].queryset = Section.objects.filter(
|
||||||
|
department_id=department_id
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
|
||||||
class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
class SLAConfigForm(HospitalFieldMixin, forms.ModelForm):
|
||||||
@ -1054,7 +981,38 @@ class PublicInquiryForm(forms.Form):
|
|||||||
queryset=Hospital.objects.filter(status="active").order_by("name"),
|
queryset=Hospital.objects.filter(status="active").order_by("name"),
|
||||||
empty_label=_("Select Hospital"),
|
empty_label=_("Select Hospital"),
|
||||||
required=True,
|
required=True,
|
||||||
widget=forms.Select(attrs={"class": "form-control"}),
|
widget=forms.Select(attrs={"class": "form-control", "id": "hospital_select"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
location_type = forms.ChoiceField(
|
||||||
|
label=_("Location Type"),
|
||||||
|
choices=[("", _("Select Location Type"))] + list(LocationType.choices),
|
||||||
|
required=True,
|
||||||
|
widget=forms.Select(attrs={"class": "form-control", "id": "location_type_select"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
area = forms.ModelChoiceField(
|
||||||
|
label=_("Area (Optional)"),
|
||||||
|
queryset=Area.objects.none(),
|
||||||
|
empty_label=_("Select Area"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-control", "id": "area_select"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
department = forms.ModelChoiceField(
|
||||||
|
label=_("Department (Optional)"),
|
||||||
|
queryset=Department.objects.none(),
|
||||||
|
empty_label=_("Select Department"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}),
|
||||||
|
)
|
||||||
|
|
||||||
|
section = forms.ModelChoiceField(
|
||||||
|
label=_("Section (Optional)"),
|
||||||
|
queryset=Section.objects.none(),
|
||||||
|
empty_label=_("Select Section"),
|
||||||
|
required=False,
|
||||||
|
widget=forms.Select(attrs={"class": "form-control", "id": "section_select"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
category = forms.ChoiceField(
|
category = forms.ChoiceField(
|
||||||
@ -1083,6 +1041,36 @@ class PublicInquiryForm(forms.Form):
|
|||||||
widget=forms.Textarea(attrs={"class": "form-control", "rows": 5, "placeholder": _("Describe your inquiry")}),
|
widget=forms.Textarea(attrs={"class": "form-control", "rows": 5, "placeholder": _("Describe your inquiry")}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["area"].queryset = Area.objects.none()
|
||||||
|
self.fields["section"].queryset = Section.objects.none()
|
||||||
|
|
||||||
|
hospital_id = None
|
||||||
|
if "hospital" in self.initial:
|
||||||
|
hospital_id = self.initial["hospital"]
|
||||||
|
elif "hospital" in self.data:
|
||||||
|
hospital_id = self.data["hospital"]
|
||||||
|
|
||||||
|
if hospital_id:
|
||||||
|
self.fields["department"].queryset = Department.objects.filter(
|
||||||
|
hospital_id=hospital_id, status="active"
|
||||||
|
).order_by("name")
|
||||||
|
self.fields["area"].queryset = Area.objects.filter(
|
||||||
|
hospital_id=hospital_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
department_id = None
|
||||||
|
if "department" in self.initial:
|
||||||
|
department_id = self.initial["department"]
|
||||||
|
elif "department" in self.data:
|
||||||
|
department_id = self.data["department"]
|
||||||
|
|
||||||
|
if department_id:
|
||||||
|
self.fields["section"].queryset = Section.objects.filter(
|
||||||
|
department_id=department_id, status="active"
|
||||||
|
).order_by("name_en")
|
||||||
|
|
||||||
|
|
||||||
class ComplaintInvolvedDepartmentForm(forms.ModelForm):
|
class ComplaintInvolvedDepartmentForm(forms.ModelForm):
|
||||||
"""
|
"""
|
||||||
@ -1246,9 +1234,8 @@ class GovernmentTicketForm(forms.ModelForm):
|
|||||||
"complainant_name",
|
"complainant_name",
|
||||||
"national_id",
|
"national_id",
|
||||||
"contact_number",
|
"contact_number",
|
||||||
"location",
|
"department",
|
||||||
"main_section",
|
"section",
|
||||||
"subsection",
|
|
||||||
"received_date",
|
"received_date",
|
||||||
"classification",
|
"classification",
|
||||||
"content",
|
"content",
|
||||||
@ -1286,11 +1273,8 @@ class GovernmentTicketForm(forms.ModelForm):
|
|||||||
self.fields["source"].queryset = self.fields["source"].queryset.filter(
|
self.fields["source"].queryset = self.fields["source"].queryset.filter(
|
||||||
source_type="government", is_active=True
|
source_type="government", is_active=True
|
||||||
)
|
)
|
||||||
self.fields["main_section"].required = False
|
|
||||||
self.fields["subsection"].required = False
|
|
||||||
self.fields["national_id"].required = False
|
self.fields["national_id"].required = False
|
||||||
self.fields["contact_number"].required = False
|
self.fields["contact_number"].required = False
|
||||||
self.fields["location"].required = False
|
|
||||||
self.fields["classification"].required = False
|
self.fields["classification"].required = False
|
||||||
self.fields["assigned_to"].required = False
|
self.fields["assigned_to"].required = False
|
||||||
|
|
||||||
@ -1298,15 +1282,11 @@ class GovernmentTicketForm(forms.ModelForm):
|
|||||||
from apps.core.utils import get_assignable_users
|
from apps.core.utils import get_assignable_users
|
||||||
self.fields["assigned_to"].queryset = get_assignable_users(hospital)
|
self.fields["assigned_to"].queryset = get_assignable_users(hospital)
|
||||||
|
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
from apps.organizations.models import Section
|
||||||
if args and args[0]:
|
if args and args[0]:
|
||||||
self.fields["location"].queryset = Location.objects.all()
|
self.fields["section"].queryset = Section.objects.all()
|
||||||
self.fields["main_section"].queryset = MainSection.objects.all()
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.all()
|
|
||||||
else:
|
else:
|
||||||
self.fields["location"].queryset = Location.objects.none()
|
self.fields["section"].queryset = Section.objects.none()
|
||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
|
||||||
|
|
||||||
def clean_ticket_number(self):
|
def clean_ticket_number(self):
|
||||||
ticket_number = self.cleaned_data.get("ticket_number")
|
ticket_number = self.cleaned_data.get("ticket_number")
|
||||||
|
|||||||
404
apps/complaints/management/commands/arabic_dept_mapping.py
Normal file
404
apps/complaints/management/commands/arabic_dept_mapping.py
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
"""
|
||||||
|
Arabic sub-department name -> Department code mapping.
|
||||||
|
|
||||||
|
Used by complaint import scripts to set the `department` FK based on the
|
||||||
|
Arabic القسم الفرعي (sub-department) column in the historical Excel files.
|
||||||
|
|
||||||
|
All codes target HH-N (Al Nuzha) since historical data is from that hospital.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_arabic(text: str) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
text = str(text).strip()
|
||||||
|
text = text.replace("\u0623", "\u0627") # أ → ا
|
||||||
|
text = text.replace("\u0625", "\u0627") # إ → ا
|
||||||
|
text = text.replace("\u0622", "\u0627") # آ → ا
|
||||||
|
text = text.replace("\u0649", "\u064a") # ى → ي
|
||||||
|
text = re.sub(r"\s+", " ", text)
|
||||||
|
text = text.strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# Canonical Arabic name → department code
|
||||||
|
# All codes are HH-N (Nuzha) department codes
|
||||||
|
ARABIC_TO_DEPT_CODE = {
|
||||||
|
# Pharmacy
|
||||||
|
"قسم الصيدلية": "hh_n_pharmacy_department",
|
||||||
|
"صيدلية الطوارئ": "hh_n_pharmacy_department",
|
||||||
|
"قسم الصيدلية - السويدي": "hh_n_pharmacy_department",
|
||||||
|
# Laboratory
|
||||||
|
"قسم المختبر": "hh_n_laboratory_department",
|
||||||
|
"استقبال المختبر": "hh_n_laboratory_department",
|
||||||
|
"بنك الدم": "hh_n_laboratory_department",
|
||||||
|
# Medical Approvals
|
||||||
|
"قسم الموافقات الطبية": "hh_n_medical_approvals_department",
|
||||||
|
"الموافقات الطبية": "hh_n_medical_approvals_department",
|
||||||
|
# Medical Records
|
||||||
|
"قسم التقارير الطبية": "hh_n_medical_records_department",
|
||||||
|
# Financial
|
||||||
|
"قسم المالية": "hh_n_financial_collection___claims_department",
|
||||||
|
# Dental
|
||||||
|
"عيادات الأسنان": "hh_n_dental_department",
|
||||||
|
# Ophthalmology
|
||||||
|
"عيادات العيون": "hh_n_ophthalmology_department",
|
||||||
|
"عيادات جراحة العيون": "hh_n_ophthalmology_department",
|
||||||
|
"فني فحص النظر": "hh_n_ophthalmology_department",
|
||||||
|
# Dermatology
|
||||||
|
"عيادات الجلدية": "hh_n_dermatology_department",
|
||||||
|
"عيادات الجلديه": "hh_n_dermatology_department",
|
||||||
|
# OB/GYN
|
||||||
|
"عيادات النساء والولادة": "hh_n_obstetrics___gynecology_department",
|
||||||
|
"تنويم النساء والولادة": "hh_n_obstetrics___gynecology_department",
|
||||||
|
"تنويم النساء والولاده": "hh_n_obstetrics___gynecology_department",
|
||||||
|
"قسم عمليات الولادة": "hh_n_obstetrics___gynecology_department",
|
||||||
|
"تمريض تنويم النساء والولادة": "hh_n_obstetrics___gynecology_department",
|
||||||
|
"تمريض قسم عمليات الولادة": "hh_n_nursing_department",
|
||||||
|
# Pediatric
|
||||||
|
"عيادات الأطفال": "hh_n_pediatric_department",
|
||||||
|
"تنويم الأطفال": "hh_n_pediatric_department",
|
||||||
|
"تنويم الاطفال": "hh_n_pediatric_department",
|
||||||
|
"تمريض تنويم الأطفال": "hh_n_pediatric_department",
|
||||||
|
"تمريض تنويم الاطفال": "hh_n_pediatric_department",
|
||||||
|
"عيادات الأنف والأذن والحنجرة (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات الصدرية (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات الجهاز الهمضي والمناظير (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات جراحة عظام (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات جراحة المسالك البولية (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات قلب (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات المخ والأعصاب (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"عيادات الجراحة العامة (أطفال)": "hh_n_pediatric_department",
|
||||||
|
"تمريض الحضانة": "hh_n_pediatric_department",
|
||||||
|
"وحدة العناية المركزة لحديثي الولادة": "hh_n_pediatric_department",
|
||||||
|
"وحدة العناية المركزة للأطفال": "hh_n_pediatric_department",
|
||||||
|
"قسم الحضانة": "hh_n_pediatric_department",
|
||||||
|
"تمريض وحدة العناية المركزة - أطفال": "hh_n_pediatric_department",
|
||||||
|
"تمريض وحدة العناية لحديثي الولادة": "hh_n_pediatric_department",
|
||||||
|
# Surgery
|
||||||
|
"عيادات جراحة العظام": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحه العظام": "hh_n_surgery_department",
|
||||||
|
"قسم جراحة العظام": "hh_n_surgery_department",
|
||||||
|
"عيادات الجراحة العامة": "hh_n_surgery_department",
|
||||||
|
"قسم الجراحة العامة": "hh_n_surgery_department",
|
||||||
|
"قسم جراحة المسالك البولية": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة المسالك البولية": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة الأنف وأذن وحنجرة": "hh_n_surgery_department",
|
||||||
|
"جراحه الانف واذن وحنجره": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة الأوعية الدموية": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة المخ والأعصاب": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة القفص الصدري": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة العمود الفقري": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة التجميلية": "hh_n_surgery_department",
|
||||||
|
"عيادات الجراحة التجميلية": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة سمنة": "hh_n_surgery_department",
|
||||||
|
"قسم جراحة السمنة": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة الختان": "hh_n_surgery_department",
|
||||||
|
"عيادات جراحة الأورام و الغدد الصماء": "hh_n_surgery_department",
|
||||||
|
"تمريض تنويم الجراحة العامة": "hh_n_surgery_department",
|
||||||
|
"تنويم الجراحة العامة": "hh_n_surgery_department",
|
||||||
|
"تنويم الباطنية": "hh_n_internal_medicine_department",
|
||||||
|
# Internal Medicine
|
||||||
|
"عيادات الباطنية": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات القلب": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات الجهاز الهضمي والمناظير": "hh_n_internal_medicine_department",
|
||||||
|
"قسم الجهاز الهمضي و الكبد والمناظير": "hh_n_internal_medicine_department",
|
||||||
|
"قسم الجهاز الهضمي والكبد والمناظير": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات الصدرية": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات الصدريه": "hh_n_internal_medicine_department",
|
||||||
|
"قسم الصدرية": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات الغدد الصماء": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات المخ والأعصاب": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات الكلى": "hh_n_internal_medicine_department",
|
||||||
|
"عيادات التخدير": "hh_n_anesthesia_department",
|
||||||
|
"عيادات الطب النفسي": "hh_n_internal_medicine_department",
|
||||||
|
"وحدة المناظير": "hh_n_internal_medicine_department",
|
||||||
|
"تمريض تنويم الباطنية": "hh_n_internal_medicine_department",
|
||||||
|
"قسم العلاج الطبيعي": "hh_n_medical_ancillary_services_department",
|
||||||
|
"استقبال العلاج الطبيعي": "hh_n_medical_ancillary_services_department",
|
||||||
|
"العلاج الطبيعي": "hh_n_medical_ancillary_services_department",
|
||||||
|
# Anesthesia
|
||||||
|
"قسم التخدير": "hh_n_anesthesia_department",
|
||||||
|
"تمريض التخدير": "hh_n_anesthesia_department",
|
||||||
|
# Emergency
|
||||||
|
"أطباء الطوارئ": "hh_n_emergency_medicine_department",
|
||||||
|
"استقبال الطوارئ": "hh_n_emergency_administrative_department",
|
||||||
|
"استقبال الطواريء": "hh_n_emergency_administrative_department",
|
||||||
|
"استقبال العيادات الخارجية": "hh_n_outpatient_department",
|
||||||
|
# Critical Care
|
||||||
|
"وحدة العناية المركزة": "hh_n_critical_care_department",
|
||||||
|
"وحدة العناية المتوسطة": "hh_n_critical_care_department",
|
||||||
|
"تمريض وحدة العناية المركزة": "hh_n_critical_care_department",
|
||||||
|
"تمريض وحدة العناية المتوسطة": "hh_n_critical_care_department",
|
||||||
|
"تمريض وحدة طويلي الإقامة": "hh_n_critical_care_department",
|
||||||
|
"وحدة مرضى طويلي الإقامة": "hh_n_critical_care_department",
|
||||||
|
# Nursing
|
||||||
|
"تمريض الطوارئ": "hh_n_nursing_department",
|
||||||
|
"تمريض الطوارى": "hh_n_nursing_department",
|
||||||
|
"تمريض العيادات الخارجية": "hh_n_nursing_department",
|
||||||
|
"تمريض العيادات الخارجيه": "hh_n_nursing_department",
|
||||||
|
"تمريض غرفة التطعيمات": "hh_n_nursing_department",
|
||||||
|
"تمريض غرفة تقديم الأدوية الوريدية (20)": "hh_n_nursing_department",
|
||||||
|
"تمريض قسم العمليات": "hh_n_nursing_department",
|
||||||
|
"تمريض قسم الافاقة": "hh_n_nursing_department",
|
||||||
|
"تمريض قسم الإفاقة": "hh_n_nursing_department",
|
||||||
|
"التمريض": "hh_n_nursing_department",
|
||||||
|
# Radiology
|
||||||
|
"قسم الأشعة": "hh_n_radiology_department",
|
||||||
|
"قسم الاشعة": "hh_n_radiology_department",
|
||||||
|
"استقبال الأشعة": "hh_n_radiology_department",
|
||||||
|
"الأشعة": "hh_n_radiology_department",
|
||||||
|
# Operating Rooms
|
||||||
|
"قسم العمليات": "hh_n_operating_rooms__or__department",
|
||||||
|
# Outpatient / Inpatient administration
|
||||||
|
"إدارة التنويم": "hh_n_inpatient_department",
|
||||||
|
"اداره التنويم": "hh_n_inpatient_department",
|
||||||
|
"مكتب التنويم": "hh_n_inpatient_department",
|
||||||
|
"إدارة العيادات الخارجيه": "hh_n_outpatient_department",
|
||||||
|
"قسم المواعيد": "hh_n_contact_center_department",
|
||||||
|
"قسم السنترال": "hh_n_contact_center_department",
|
||||||
|
"قسم تقنية المعلومات": "hh_n_information_technology_department",
|
||||||
|
# Housekeeping
|
||||||
|
"قسم النظافة": "hh_n_housekeeping___hospitality_department",
|
||||||
|
"النظافة": "hh_n_housekeeping___hospitality_department",
|
||||||
|
"النظافه": "hh_n_housekeeping___hospitality_department",
|
||||||
|
# Security
|
||||||
|
"قسم الأمن": "hh_n_security_department",
|
||||||
|
"قسم الامن": "hh_n_security_department",
|
||||||
|
# Food Services
|
||||||
|
"قسم المطبخ": "hh_n_food_services_department",
|
||||||
|
"المطبخ": "hh_n_food_services_department",
|
||||||
|
"قسم التغذية": "hh_n_food_services_department",
|
||||||
|
# Facility Management
|
||||||
|
"قسم الصيانة": "hh_n_facility_management___maintenance_department",
|
||||||
|
# Executive / Admin
|
||||||
|
"المدير المناوب": "hh_n_executive_administration",
|
||||||
|
"قسم الإدارة": "hh_n_executive_administration",
|
||||||
|
"التنسيق": "hh_n_executive_administration",
|
||||||
|
# Social Services
|
||||||
|
"قسم الخدمة الإجتماعية": "hh_n_inpatient_department",
|
||||||
|
# Patient Affairs
|
||||||
|
"قسم علاقات المرضى": "hh_n_patient_affairs_department",
|
||||||
|
# Medical Ancillary / Respiratory / Audiology / Neurophysiology
|
||||||
|
"فني اختبار السمع": "hh_n_medical_ancillary_services_department",
|
||||||
|
"فني تخطيط القلب": "hh_n_medical_ancillary_services_department",
|
||||||
|
"فني تخطيط المخ والأعصاب": "hh_n_medical_ancillary_services_department",
|
||||||
|
"فني تخطيط المخ والاعصاب": "hh_n_medical_ancillary_services_department",
|
||||||
|
"فني دراسة الجهد القلب": "hh_n_medical_ancillary_services_department",
|
||||||
|
"قسم العلاج التنفسي": "hh_n_medical_ancillary_services_department",
|
||||||
|
# IVF
|
||||||
|
"عيادات العقم والإنجاب": "hh_n_ivf",
|
||||||
|
# Oncology
|
||||||
|
"عيادات جراحة الأورام و الغدد الصماء": "hh_n_oncology_department",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Arabic name → (dept_code, section_name_en)
|
||||||
|
# Only entries where we can confidently identify the specific section.
|
||||||
|
# Generic names (e.g. "قسم الصيدلية") are excluded — they map to dept only.
|
||||||
|
ARABIC_TO_SECTION = {
|
||||||
|
# Pharmacy
|
||||||
|
"صيدلية الطوارئ": ("hh_n_pharmacy_department", "ER Pharmacy"),
|
||||||
|
# Laboratory
|
||||||
|
"بنك الدم": ("hh_n_laboratory_department", "Blood Donation & Blood Bank"),
|
||||||
|
"استقبال المختبر": ("hh_n_laboratory_department", "Receiving ِArea"),
|
||||||
|
# OB/GYN
|
||||||
|
"عيادات النساء والولادة": ("hh_n_obstetrics___gynecology_department", "Clinics"),
|
||||||
|
"تنويم النساء والولادة": ("hh_n_obstetrics___gynecology_department", "Wards"),
|
||||||
|
"تنويم النساء والولاده": ("hh_n_obstetrics___gynecology_department", "Wards"),
|
||||||
|
"قسم عمليات الولادة": ("hh_n_obstetrics___gynecology_department", "Labor & Delivery (L&D) / Obstetrics Operating Rooms"),
|
||||||
|
"تمريض تنويم النساء والولادة": ("hh_n_obstetrics___gynecology_department", "Wards"),
|
||||||
|
# Surgery
|
||||||
|
"عيادات جراحة العظام": ("hh_n_surgery_department", "Orthopedic Surgery"),
|
||||||
|
"عيادات جراحه العظام": ("hh_n_surgery_department", "Orthopedic Surgery"),
|
||||||
|
"قسم جراحة العظام": ("hh_n_surgery_department", "Orthopedic Surgery"),
|
||||||
|
"عيادات الجراحة العامة": ("hh_n_surgery_department", "General Surgery"),
|
||||||
|
"قسم الجراحة العامة": ("hh_n_surgery_department", "General Surgery"),
|
||||||
|
"قسم جراحة المسالك البولية": ("hh_n_surgery_department", "Urology"),
|
||||||
|
"عيادات جراحة المسالك البولية": ("hh_n_surgery_department", "Urology"),
|
||||||
|
"عيادات جراحة الأنف وأذن وحنجرة": ("hh_n_surgery_department", "ENT"),
|
||||||
|
"جراحه الانف واذن وحنجره": ("hh_n_surgery_department", "ENT"),
|
||||||
|
"عيادات جراحة الأوعية الدموية": ("hh_n_surgery_department", "Vscular Surgery"),
|
||||||
|
"عيادات جراحة المخ والأعصاب": ("hh_n_surgery_department", "Neurosurgery"),
|
||||||
|
"عيادات جراحة القفص الصدري": ("hh_n_surgery_department", "Thoracic Surgery"),
|
||||||
|
"عيادات جراحة العمود الفقري": ("hh_n_surgery_department", "Spine Surgery"),
|
||||||
|
"عيادات جراحة التجميلية": ("hh_n_surgery_department", "Plastic Surgery"),
|
||||||
|
"عيادات الجراحة التجميلية": ("hh_n_surgery_department", "Plastic Surgery"),
|
||||||
|
"عيادات جراحة سمنة": ("hh_n_surgery_department", "Bariatric"),
|
||||||
|
"قسم جراحة السمنة": ("hh_n_surgery_department", "Bariatric"),
|
||||||
|
"عيادات جراحة الأورام و الغدد الصماء": ("hh_n_oncology_department", "Hematology"),
|
||||||
|
"تمريض تنويم الجراحة العامة": ("hh_n_nursing_department", "Surgical Ward"),
|
||||||
|
"تنويم الجراحة العامة": ("hh_n_nursing_department", "Surgical Ward"),
|
||||||
|
# Internal Medicine
|
||||||
|
"عيادات القلب": ("hh_n_internal_medicine_department", "Cardiology"),
|
||||||
|
"عيادات الجهاز الهضمي والمناظير": ("hh_n_internal_medicine_department", "Gastroenterology"),
|
||||||
|
"قسم الجهاز الهمضي و الكبد والمناظير": ("hh_n_internal_medicine_department", "Gastroenterology"),
|
||||||
|
"قسم الجهاز الهضمي والكبد والمناظير": ("hh_n_internal_medicine_department", "Gastroenterology"),
|
||||||
|
"عيادات الصدرية": ("hh_n_internal_medicine_department", "Pulmonology"),
|
||||||
|
"عيادات الصدريه": ("hh_n_internal_medicine_department", "Pulmonology"),
|
||||||
|
"قسم الصدرية": ("hh_n_internal_medicine_department", "Pulmonology"),
|
||||||
|
"عيادات الغدد الصماء": ("hh_n_internal_medicine_department", "Endocrinology"),
|
||||||
|
"عيادات المخ والأعصاب": ("hh_n_internal_medicine_department", "Neurology"),
|
||||||
|
"عيادات الكلى": ("hh_n_internal_medicine_department", "Nephrology"),
|
||||||
|
"عيادات الطب النفسي": ("hh_n_internal_medicine_department", "Psychiatry"),
|
||||||
|
"وحدة المناظير": ("hh_n_internal_medicine_department", "Endoscopy"),
|
||||||
|
"تمريض تنويم الباطنية": ("hh_n_nursing_department", "Medical Ward"),
|
||||||
|
"تنويم الباطنية": ("hh_n_nursing_department", "Medical Ward"),
|
||||||
|
"عيادات الباطنية": ("hh_n_internal_medicine_department", "Internal Medicine"),
|
||||||
|
# Emergency
|
||||||
|
"استقبال الطوارئ": ("hh_n_emergency_administrative_department", "Emergency Reception"),
|
||||||
|
"استقبال الطواريء": ("hh_n_emergency_administrative_department", "Emergency Reception"),
|
||||||
|
"استقبال العيادات الخارجية": ("hh_n_outpatient_department", "Outpatient Reception"),
|
||||||
|
# Critical Care
|
||||||
|
"وحدة العناية المركزة": ("hh_n_critical_care_department", "ICU"),
|
||||||
|
"تمريض وحدة العناية المركزة": ("hh_n_nursing_department", "ICU/CCU"),
|
||||||
|
"وحدة العناية المتوسطة": ("hh_n_critical_care_department", "ICU Stepdown"),
|
||||||
|
"تمريض وحدة العناية المتوسطة": ("hh_n_nursing_department", "ICU/CCU Stepdown"),
|
||||||
|
"تمريض وحدة طويلي الإقامة": ("hh_n_nursing_department", "LTACU"),
|
||||||
|
"وحدة مرضى طويلي الإقامة": ("hh_n_internal_medicine_department", "Long-Term Acute Care Unit (LTACU)"),
|
||||||
|
# Nursing
|
||||||
|
"تمريض الطوارئ": ("hh_n_nursing_department", "Emergency"),
|
||||||
|
"تمريض الطوارى": ("hh_n_nursing_department", "Emergency"),
|
||||||
|
"تمريض العيادات الخارجية": ("hh_n_nursing_department", "Outpatient"),
|
||||||
|
"تمريض العيادات الخارجيه": ("hh_n_nursing_department", "Outpatient"),
|
||||||
|
"تمريض قسم العمليات": ("hh_n_nursing_department", "Main OR"),
|
||||||
|
"تمريض قسم الافاقة": ("hh_n_nursing_department", "Recovery"),
|
||||||
|
"تمريض قسم الإفاقة": ("hh_n_nursing_department", "Recovery"),
|
||||||
|
"تمريض قسم عمليات الولادة": ("hh_n_nursing_department", "OB/OR"),
|
||||||
|
# Pediatric
|
||||||
|
"تنويم الأطفال": ("hh_n_pediatric_department", "Pediatric Ward"),
|
||||||
|
"تنويم الاطفال": ("hh_n_pediatric_department", "Pediatric Ward"),
|
||||||
|
"تمريض تنويم الأطفال": ("hh_n_nursing_department", "Pediatric Ward"),
|
||||||
|
"تمريض تنويم الاطفال": ("hh_n_nursing_department", "Pediatric Ward"),
|
||||||
|
"عيادات الأطفال": ("hh_n_pediatric_department", "General Pediatrics"),
|
||||||
|
"وحدة العناية المركزة لحديثي الولادة": ("hh_n_pediatric_department", "NICU"),
|
||||||
|
"تمريض وحدة العناية المركزة - أطفال": ("hh_n_nursing_department", "PICU"),
|
||||||
|
"وحدة العناية المركزة للأطفال": ("hh_n_pediatric_department", "PICU"),
|
||||||
|
"تمريض وحدة العناية لحديثي الولادة": ("hh_n_nursing_department", "NICU"),
|
||||||
|
"تمريض الحضانة": ("hh_n_nursing_department", "Nursery"),
|
||||||
|
"قسم الحضانة": ("hh_n_pediatric_department", "NURSERY"),
|
||||||
|
"عيادات الصدرية (أطفال)": ("hh_n_pediatric_department", "Pediatric Pulmonology"),
|
||||||
|
"عيادات جراحة عظام (أطفال)": ("hh_n_pediatric_department", "Pediatric Orthopedic Surgery"),
|
||||||
|
"عيادات الأنف والأذن والحنجرة (أطفال)": ("hh_n_pediatric_department", "Pediatric Otolaryngology (ENT)"),
|
||||||
|
"عيادات الجهاز الهمضي والمناظير (أطفال)": ("hh_n_pediatric_department", "Pediatric Gastroenterology"),
|
||||||
|
"عيادات قلب (أطفال)": ("hh_n_pediatric_department", "Pediatric Cardiology"),
|
||||||
|
"عيادات المخ والأعصاب (أطفال)": ("hh_n_pediatric_department", "Pediatric Neurology"),
|
||||||
|
"عيادات جراحة المسالك البولية (أطفال)": ("hh_n_pediatric_department", "Pediatric Urology"),
|
||||||
|
"عيادات الجراحة العامة (أطفال)": ("hh_n_pediatric_department", "Pediatric General Surgery"),
|
||||||
|
"عيادات جراحة الختان": ("hh_n_pediatric_department", "Circumcision"),
|
||||||
|
# Outpatient
|
||||||
|
"إدارة العيادات الخارجيه": ("hh_n_outpatient_department", "Management"),
|
||||||
|
# Medical Ancillary
|
||||||
|
"فني اختبار السمع": ("hh_n_medical_ancillary_services_department", "Swallowing Speech and Hearing Unit"),
|
||||||
|
"فني تخطيط القلب": ("hh_n_medical_ancillary_services_department", "Cardiac Physiology Services"),
|
||||||
|
"فني تخطيط المخ والأعصاب": ("hh_n_medical_ancillary_services_department", "Neurophysiology Services"),
|
||||||
|
"فني تخطيط المخ والاعصاب": ("hh_n_medical_ancillary_services_department", "Neurophysiology Services"),
|
||||||
|
"فني دراسة الجهد القلب": ("hh_n_medical_ancillary_services_department", "Cardiac Physiology Services"),
|
||||||
|
"قسم العلاج الطبيعي": ("hh_n_medical_ancillary_services_department", "Physical Therapy"),
|
||||||
|
"استقبال العلاج الطبيعي": ("hh_n_medical_ancillary_services_department", "Physical Therapy"),
|
||||||
|
"العلاج الطبيعي": ("hh_n_medical_ancillary_services_department", "Physical Therapy"),
|
||||||
|
"قسم العلاج التنفسي": ("hh_n_medical_ancillary_services_department", "Respiratory Therapy"),
|
||||||
|
"فني فحص النظر": ("hh_n_medical_ancillary_services_department", "Optometrist Services"),
|
||||||
|
"قسم التغذية": ("hh_n_medical_ancillary_services_department", "Clinical Nutrition"),
|
||||||
|
# Anesthesia
|
||||||
|
"تمريض التخدير": ("hh_n_nursing_department", "Anesthesia"),
|
||||||
|
# Radiology
|
||||||
|
"استقبال الأشعة": ("hh_n_radiology_department", "General"),
|
||||||
|
# Inpatient
|
||||||
|
"قسم الخدمة الإجتماعية": ("hh_n_inpatient_department", "Social Worker"),
|
||||||
|
# Contact Center
|
||||||
|
"قسم المواعيد": ("hh_n_contact_center_department", "Appointment Office"),
|
||||||
|
"قسم السنترال": ("hh_n_contact_center_department", "Operator Office"),
|
||||||
|
# Dermatology (1 section)
|
||||||
|
"عيادات الجلدية": ("hh_n_dermatology_department", "Dermatology"),
|
||||||
|
"عيادات الجلديه": ("hh_n_dermatology_department", "Dermatology"),
|
||||||
|
# Financial (1 section)
|
||||||
|
"قسم المالية": ("hh_n_financial_collection___claims_department", "Financial Collection & Claims"),
|
||||||
|
# Security
|
||||||
|
"قسم الأمن": ("hh_n_security_department", "Security Guards"),
|
||||||
|
"قسم الامن": ("hh_n_security_department", "Security Guards"),
|
||||||
|
# Radiology (generic → General)
|
||||||
|
"قسم الأشعة": ("hh_n_radiology_department", "General"),
|
||||||
|
"قسم الاشعة": ("hh_n_radiology_department", "General"),
|
||||||
|
"الأشعة": ("hh_n_radiology_department", "General"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_department(arabic_name: str, hospital_code: str = "HH-N"):
|
||||||
|
"""
|
||||||
|
Resolve Arabic sub-department name to a Department object.
|
||||||
|
|
||||||
|
Tries exact match first, then normalized match.
|
||||||
|
Returns Department instance or None.
|
||||||
|
"""
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
|
||||||
|
if not arabic_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = str(arabic_name).strip()
|
||||||
|
|
||||||
|
# Direct lookup
|
||||||
|
code = ARABIC_TO_DEPT_CODE.get(name)
|
||||||
|
if not code:
|
||||||
|
# Try normalized
|
||||||
|
normalized = normalize_arabic(name)
|
||||||
|
code = ARABIC_TO_DEPT_CODE.get(normalized)
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
# Try building a normalized version of all keys
|
||||||
|
for key, dept_code in ARABIC_TO_DEPT_CODE.items():
|
||||||
|
if normalize_arabic(key) == normalize_arabic(name):
|
||||||
|
code = dept_code
|
||||||
|
break
|
||||||
|
|
||||||
|
if code:
|
||||||
|
try:
|
||||||
|
return Department.objects.select_related("hospital").get(
|
||||||
|
hospital__code=hospital_code, code=code
|
||||||
|
)
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_section(arabic_name: str, hospital_code: str = "HH-N"):
|
||||||
|
"""
|
||||||
|
Resolve Arabic sub-department name to a (Department, Section) tuple.
|
||||||
|
|
||||||
|
Returns (Department, Section) if section can be identified,
|
||||||
|
(Department, None) if only department is known, or (None, None).
|
||||||
|
"""
|
||||||
|
from apps.organizations.models import Department, Section
|
||||||
|
|
||||||
|
if not arabic_name:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
name = str(arabic_name).strip()
|
||||||
|
|
||||||
|
entry = ARABIC_TO_SECTION.get(name)
|
||||||
|
if not entry:
|
||||||
|
normalized = normalize_arabic(name)
|
||||||
|
for key, val in ARABIC_TO_SECTION.items():
|
||||||
|
if normalize_arabic(key) == normalized:
|
||||||
|
entry = val
|
||||||
|
break
|
||||||
|
|
||||||
|
if entry:
|
||||||
|
dept_code, section_name = entry
|
||||||
|
try:
|
||||||
|
dept = Department.objects.select_related("hospital").get(
|
||||||
|
hospital__code=hospital_code, code=dept_code
|
||||||
|
)
|
||||||
|
section = Section.objects.filter(
|
||||||
|
department=dept, name_en=section_name
|
||||||
|
).first()
|
||||||
|
return dept, section
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
dept = resolve_department(name, hospital_code)
|
||||||
|
return dept, None
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db.models import F
|
||||||
|
from apps.complaints.models import Complaint, ComplaintInvolvedDepartment
|
||||||
|
from apps.observations.models import Observation
|
||||||
|
from apps.complaints.models import Inquiry
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Backfill sent_to_department=True on all records that have a department assigned"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
c_updated = Complaint.objects.filter(
|
||||||
|
department__isnull=False, sent_to_department=False
|
||||||
|
).update(
|
||||||
|
sent_to_department=True,
|
||||||
|
sent_to_department_at=F("created_at"),
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Complaints updated: {c_updated}")
|
||||||
|
|
||||||
|
o_updated = Observation.objects.filter(
|
||||||
|
assigned_department__isnull=False, sent_to_department=False
|
||||||
|
).update(
|
||||||
|
sent_to_department=True,
|
||||||
|
sent_to_department_at=F("created_at"),
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Observations updated: {o_updated}")
|
||||||
|
|
||||||
|
i_updated = Inquiry.objects.filter(
|
||||||
|
department__isnull=False, sent_to_department=False
|
||||||
|
).update(
|
||||||
|
sent_to_department=True,
|
||||||
|
sent_to_department_at=F("created_at"),
|
||||||
|
)
|
||||||
|
self.stdout.write(f"Inquiries updated: {i_updated}")
|
||||||
|
|
||||||
|
cid_updated = ComplaintInvolvedDepartment.objects.filter(
|
||||||
|
sent=False, complaint__department__isnull=False
|
||||||
|
).update(sent=True)
|
||||||
|
self.stdout.write(f"Involved departments updated: {cid_updated}")
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Done"))
|
||||||
@ -68,3 +68,16 @@ def resolve_px_source(source_value: str) -> "PXSource | None":
|
|||||||
return PXSource.objects.get(code=code)
|
return PXSource.objects.get(code=code)
|
||||||
except PXSource.DoesNotExist:
|
except PXSource.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
EXTERNAL_SOURCE_CODES = {"MOH", "CCHI", "CHI"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_complaint_source_type(px_source) -> str:
|
||||||
|
"""
|
||||||
|
Determine complaint_source_type based on PXSource.
|
||||||
|
Only MOH/CHI/CCHI are external; everything else is internal.
|
||||||
|
"""
|
||||||
|
if px_source and px_source.code in EXTERNAL_SOURCE_CODES:
|
||||||
|
return "external"
|
||||||
|
return "internal"
|
||||||
|
|||||||
@ -19,13 +19,14 @@ from django.utils import timezone
|
|||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Hospital, LegacyLocation, LegacyMainSection, LegacySubSection
|
||||||
|
|
||||||
from .complaint_source_mapping import resolve_px_source
|
from .complaint_source_mapping import resolve_px_source, get_complaint_source_type
|
||||||
|
from .arabic_dept_mapping import resolve_section as resolve_dept_and_section
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_HOSPITAL_CODE = "NUZHA"
|
DEFAULT_HOSPITAL_CODE = "HH-N"
|
||||||
|
|
||||||
# Header aliases: list of possible names in Excel for each field
|
# Header aliases: list of possible names in Excel for each field
|
||||||
HEADER_ALIASES = {
|
HEADER_ALIASES = {
|
||||||
@ -37,12 +38,39 @@ HEADER_ALIASES = {
|
|||||||
"sub_dept_name": ["القسم الفرعي"],
|
"sub_dept_name": ["القسم الفرعي"],
|
||||||
"date_received": ["تاريخ إستلام الشكوى"],
|
"date_received": ["تاريخ إستلام الشكوى"],
|
||||||
"data_entry_person": ["المدخل"],
|
"data_entry_person": ["المدخل"],
|
||||||
|
# Timeline columns
|
||||||
|
"form_sent_date": ["إرسال نموذج الشكوى"],
|
||||||
|
"activated_date": ["تفعيل الشكوى"],
|
||||||
|
"sent_date": ["تم ارسال الشكوى"],
|
||||||
|
"first_reminder": ["First Reminder Sent"],
|
||||||
|
"second_reminder": ["Second Reminder Sent"],
|
||||||
|
"escalated_date": ["Escalated"],
|
||||||
|
"closed_date": ["Closed"],
|
||||||
|
"resolved_date": ["Resolved"],
|
||||||
"response_date": ["تاريخ الرد"],
|
"response_date": ["تاريخ الرد"],
|
||||||
|
# Complaint details
|
||||||
"staff_name": ["اسم الشخص المشتكى عليه - ان وجد", "اسم الشخص المشتكى عليه"],
|
"staff_name": ["اسم الشخص المشتكى عليه - ان وجد", "اسم الشخص المشتكى عليه"],
|
||||||
|
"complaint_subject": ["موضوع الشكوى الأساسية"],
|
||||||
"description_ar": ["الشكوى باختصار (عربي)", "محتوى الشكوى (عربي)"],
|
"description_ar": ["الشكوى باختصار (عربي)", "محتوى الشكوى (عربي)"],
|
||||||
"description_en": ["الشكوى باختصار English", "محتوى الشكوى (English)"],
|
"description_en": ["الشكوى باختصار English", "محتوى الشكوى (English)"],
|
||||||
"satisfaction": ["توثيق تذكيرات للقسم المشتكى عليه"],
|
"satisfaction": ["Satisfied/Dissatisfied"],
|
||||||
"reminder_date": ["تاريخ التذكير"],
|
"rightful_side": ["The Rightful Side"],
|
||||||
|
# Rich data
|
||||||
|
"delay_reason": ["سبب تأخير القسم بالرد"],
|
||||||
|
"closure_delay": ["سبب تأخير اغلاق الشكوى خلال 72 ساعه"],
|
||||||
|
"action_taken": ["الاجراء المتخذ من قبل القسم المعني"],
|
||||||
|
"action_result": ["نتيجة الاجراء المتخذ بعد التحقيق"],
|
||||||
|
"recommendation": ["Recommendation/Action plan"],
|
||||||
|
"solutions": ["حلول واقتراحات"],
|
||||||
|
}
|
||||||
|
|
||||||
|
SATISFACTION_MAP = {
|
||||||
|
"satisfied": "satisfied",
|
||||||
|
"dissatisfied": "dissatisfied",
|
||||||
|
"no response": "no_response",
|
||||||
|
"no_response": "no_response",
|
||||||
|
"neutral": "neutral",
|
||||||
|
"escalated": "escalated",
|
||||||
}
|
}
|
||||||
|
|
||||||
MONTH_MAP = {
|
MONTH_MAP = {
|
||||||
@ -68,15 +96,17 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument("excel_file", type=str)
|
parser.add_argument("excel_file", type=str)
|
||||||
parser.add_argument("--sheet", type=str, default="JAN")
|
parser.add_argument("--sheet", type=str, default="JAN")
|
||||||
parser.add_argument("--dry-run", action="store_true")
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--hospital-code", type=str, default=DEFAULT_HOSPITAL_CODE)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.excel_file = options["excel_file"]
|
self.excel_file = options["excel_file"]
|
||||||
self.sheet_name = options["sheet"]
|
self.sheet_name = options["sheet"]
|
||||||
self.dry_run = options["dry_run"]
|
self.dry_run = options["dry_run"]
|
||||||
|
self.hospital_code = options["hospital_code"]
|
||||||
|
|
||||||
self.hospital = self._load_hospital()
|
self.hospital = self._load_hospital()
|
||||||
if not self.hospital:
|
if not self.hospital:
|
||||||
raise CommandError(f'Hospital "{DEFAULT_HOSPITAL_CODE}" not found')
|
raise CommandError(f'Hospital "{self.hospital_code}" not found')
|
||||||
|
|
||||||
self.stdout.write(f"Using hospital: {self.hospital.name}")
|
self.stdout.write(f"Using hospital: {self.hospital.name}")
|
||||||
|
|
||||||
@ -104,13 +134,14 @@ class Command(BaseCommand):
|
|||||||
self.stats = {"processed": 0, "success": 0, "failed": 0}
|
self.stats = {"processed": 0, "success": 0, "failed": 0}
|
||||||
self.errors = []
|
self.errors = []
|
||||||
self.used_refs = set()
|
self.used_refs = set()
|
||||||
|
self.unmapped_arabic_depts = {}
|
||||||
|
|
||||||
self._process_sheet()
|
self._process_sheet()
|
||||||
self._print_report()
|
self._print_report()
|
||||||
|
|
||||||
def _load_hospital(self) -> Optional[Hospital]:
|
def _load_hospital(self) -> Optional[Hospital]:
|
||||||
try:
|
try:
|
||||||
return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE)
|
return Hospital.objects.get(code=self.hospital_code)
|
||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -161,27 +192,72 @@ class Command(BaseCommand):
|
|||||||
created_at = date_received or timezone.now()
|
created_at = date_received or timezone.now()
|
||||||
if created_at and timezone.is_naive(created_at):
|
if created_at and timezone.is_naive(created_at):
|
||||||
created_at = timezone.make_aware(created_at)
|
created_at = timezone.make_aware(created_at)
|
||||||
|
|
||||||
|
# Parse timeline dates
|
||||||
|
form_sent_date = self._parse_datetime(row_data.get("form_sent_date"))
|
||||||
|
activated_date = self._parse_datetime(row_data.get("activated_date"))
|
||||||
|
sent_date = self._parse_datetime(row_data.get("sent_date"))
|
||||||
|
first_reminder = self._parse_datetime(row_data.get("first_reminder"))
|
||||||
|
second_reminder = self._parse_datetime(row_data.get("second_reminder"))
|
||||||
|
escalated_date = self._parse_datetime(row_data.get("escalated_date"))
|
||||||
|
closed_date = self._parse_datetime(row_data.get("closed_date"))
|
||||||
|
resolved_date = self._parse_datetime(row_data.get("resolved_date"))
|
||||||
response_date = self._parse_datetime(row_data.get("response_date"))
|
response_date = self._parse_datetime(row_data.get("response_date"))
|
||||||
reminder_date = self._parse_datetime(row_data.get("reminder_date"))
|
|
||||||
|
|
||||||
location = self._resolve_location(row_data.get("location_name"))
|
location = self._resolve_location(row_data.get("location_name"))
|
||||||
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
||||||
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
||||||
|
|
||||||
|
sub_dept_raw = row_data.get("sub_dept_name")
|
||||||
|
dept, section_obj = resolve_dept_and_section(sub_dept_raw, self.hospital_code)
|
||||||
|
if not dept and sub_dept_raw and isinstance(sub_dept_raw, str) and sub_dept_raw.strip():
|
||||||
|
name = sub_dept_raw.strip()
|
||||||
|
self.unmapped_arabic_depts[name] = self.unmapped_arabic_depts.get(name, 0) + 1
|
||||||
|
|
||||||
assigned_to_user = self._get_or_create_data_entry_user(row_data.get("data_entry_person"))
|
assigned_to_user = self._get_or_create_data_entry_user(row_data.get("data_entry_person"))
|
||||||
|
|
||||||
|
# Determine status from timeline dates
|
||||||
status = "open"
|
status = "open"
|
||||||
if response_date:
|
if closed_date:
|
||||||
|
status = "closed"
|
||||||
|
elif resolved_date:
|
||||||
status = "resolved"
|
status = "resolved"
|
||||||
|
elif escalated_date:
|
||||||
|
status = "in_progress"
|
||||||
|
|
||||||
|
# Normalize satisfaction
|
||||||
|
satisfaction_raw = str(row_data.get("satisfaction") or "").lower().strip()
|
||||||
|
satisfaction_val = SATISFACTION_MAP.get(satisfaction_raw, "")
|
||||||
|
|
||||||
|
# Normalize rightful side
|
||||||
|
rightful_side = str(row_data.get("rightful_side") or "").lower().strip()
|
||||||
|
resolution_outcome = ""
|
||||||
|
if rightful_side in ["patient", "hospital", "other"]:
|
||||||
|
resolution_outcome = rightful_side
|
||||||
|
|
||||||
|
# Rich text fields
|
||||||
|
complaint_subject = str(row_data.get("complaint_subject") or "").strip()
|
||||||
|
delay_reason = str(row_data.get("delay_reason") or "").strip()
|
||||||
|
closure_delay = str(row_data.get("closure_delay") or "").strip()
|
||||||
|
action_taken = str(row_data.get("action_taken") or "").strip()
|
||||||
|
action_result = str(row_data.get("action_result") or "").strip()
|
||||||
|
recommendation_raw = str(row_data.get("recommendation") or "").strip()
|
||||||
|
solutions = str(row_data.get("solutions") or "").strip()
|
||||||
|
recommendation_combined = recommendation_raw or solutions or ""
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
complaint = Complaint.objects.create(
|
complaint = Complaint.objects.create(
|
||||||
reference_number=ref_num,
|
reference_number=ref_num,
|
||||||
hospital=self.hospital,
|
hospital=self.hospital,
|
||||||
location=location,
|
department=dept,
|
||||||
main_section=main_section,
|
section=section_obj,
|
||||||
subsection=subsection,
|
legacy_location=location,
|
||||||
|
legacy_main_section=main_section,
|
||||||
|
legacy_subsection=subsection,
|
||||||
|
old_location_raw=str(row_data.get("location_name") or "")[:200],
|
||||||
|
old_main_section_raw=str(row_data.get("main_dept_name") or "")[:200],
|
||||||
|
old_subsection_raw=str(row_data.get("sub_dept_name") or "")[:200],
|
||||||
title=self._build_title(row_data),
|
title=self._build_title(row_data),
|
||||||
description=self._build_description(row_data),
|
description=self._build_description(row_data),
|
||||||
patient_name="Unknown",
|
patient_name="Unknown",
|
||||||
@ -195,18 +271,36 @@ class Command(BaseCommand):
|
|||||||
classification_obj=None,
|
classification_obj=None,
|
||||||
status=status,
|
status=status,
|
||||||
assigned_to=assigned_to_user,
|
assigned_to=assigned_to_user,
|
||||||
resolved_by=assigned_to_user if response_date else None,
|
resolved_by=assigned_to_user if resolved_date else None,
|
||||||
due_at=created_at + timedelta(hours=48),
|
resolution_outcome=resolution_outcome,
|
||||||
explanation_requested=bool(date_received),
|
form_sent_at=form_sent_date,
|
||||||
explanation_requested_at=date_received,
|
activated_at=activated_date,
|
||||||
|
forwarded_to_dept_at=sent_date,
|
||||||
|
reminder_sent_at=first_reminder,
|
||||||
|
second_reminder_sent_at=second_reminder,
|
||||||
|
escalated_at=escalated_date,
|
||||||
|
closed_at=closed_date,
|
||||||
|
resolved_at=resolved_date,
|
||||||
|
explanation_requested=bool(sent_date),
|
||||||
|
explanation_requested_at=sent_date,
|
||||||
explanation_received_at=response_date,
|
explanation_received_at=response_date,
|
||||||
reminder_sent_at=reminder_date,
|
due_at=created_at + timedelta(hours=48),
|
||||||
source=px_source,
|
source=px_source,
|
||||||
|
complaint_source_type=get_complaint_source_type(px_source),
|
||||||
|
satisfaction=satisfaction_val,
|
||||||
|
complaint_subject=complaint_subject,
|
||||||
|
explanation_delay_reason=delay_reason,
|
||||||
|
delay_reason_closure=closure_delay or "",
|
||||||
|
action_taken_by_dept=action_taken,
|
||||||
|
action_result=action_result,
|
||||||
|
recommendation_action_plan=recommendation_combined,
|
||||||
metadata={
|
metadata={
|
||||||
"import_source": "2025_excel_basic",
|
"import_source": "2025_excel",
|
||||||
"original_sheet": self.sheet_name,
|
"original_sheet": self.sheet_name,
|
||||||
"complaint_num": row_data.get("complaint_num"),
|
"complaint_num": row_data.get("complaint_num"),
|
||||||
},
|
},
|
||||||
|
sent_to_department=bool(dept),
|
||||||
|
sent_to_department_at=created_at if dept else None,
|
||||||
)
|
)
|
||||||
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
|
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
|
||||||
|
|
||||||
@ -276,20 +370,20 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_location(self, name_ar: str) -> Optional[Location]:
|
def _resolve_location(self, name_ar: str) -> Optional[LegacyLocation]:
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
return Location.objects.filter(name_ar=name_ar).first()
|
return LegacyLocation.objects.filter(name_ar=name_ar).first()
|
||||||
|
|
||||||
def _resolve_section(self, name_ar: str) -> Optional[MainSection]:
|
def _resolve_section(self, name_ar: str) -> Optional[LegacyMainSection]:
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
return MainSection.objects.filter(name_ar=name_ar).first()
|
return LegacyMainSection.objects.filter(name_ar=name_ar).first()
|
||||||
|
|
||||||
def _resolve_subsection(self, name_ar: str) -> Optional[SubSection]:
|
def _resolve_subsection(self, name_ar: str) -> Optional[LegacySubSection]:
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
return SubSection.objects.filter(name_ar=name_ar).first()
|
return LegacySubSection.objects.filter(name_ar=name_ar).first()
|
||||||
|
|
||||||
def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]:
|
def _get_or_create_data_entry_user(self, arabic_name: str) -> Optional[User]:
|
||||||
if not arabic_name:
|
if not arabic_name:
|
||||||
@ -355,6 +449,12 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f"Success: {self.stats['success']}")
|
self.stdout.write(f"Success: {self.stats['success']}")
|
||||||
self.stdout.write(f"Failed: {self.stats['failed']}")
|
self.stdout.write(f"Failed: {self.stats['failed']}")
|
||||||
|
|
||||||
|
if self.unmapped_arabic_depts:
|
||||||
|
self.stdout.write("\n--- Unmapped Arabic Sub-Departments ---")
|
||||||
|
sorted_unmapped = sorted(self.unmapped_arabic_depts.items(), key=lambda x: -x[1])
|
||||||
|
for name, count in sorted_unmapped:
|
||||||
|
self.stdout.write(f" {count:4d}x {name}")
|
||||||
|
|
||||||
if self.errors:
|
if self.errors:
|
||||||
self.stdout.write(f"\nErrors: {len(self.errors)}")
|
self.stdout.write(f"\nErrors: {len(self.errors)}")
|
||||||
for error in self.errors[:5]:
|
for error in self.errors[:5]:
|
||||||
|
|||||||
99
apps/complaints/management/commands/import_all_complaints.py
Normal file
99
apps/complaints/management/commands/import_all_complaints.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Import ALL historical complaints from all Excel files (2022-2025).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python manage.py import_all_complaints
|
||||||
|
python manage.py import_all_complaints --hospital-code=HH-N
|
||||||
|
python manage.py import_all_complaints --dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
|
||||||
|
COMPLAINT_FILES = [
|
||||||
|
{
|
||||||
|
"year": 2022,
|
||||||
|
"path": "data/Complaints Report - 2022.xlsx",
|
||||||
|
"sheets": ["AUG 2022 ", "SEP 2022 ", "OCT 2022", "NOV 2022", "DEC 2022"],
|
||||||
|
"importer": "import_historical_complaints",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"year": 2023,
|
||||||
|
"path": "data/Complaints Report - 2023.xlsx",
|
||||||
|
"sheets": [
|
||||||
|
"January 2023 ", "February 2023", "March 2023", "April 2023 ",
|
||||||
|
"May 2023", "June 2023", "July 2023", "August 2023",
|
||||||
|
"September 2023", "October 2023", "November 2023", "December 2023",
|
||||||
|
],
|
||||||
|
"importer": "import_historical_complaints",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"year": 2024,
|
||||||
|
"path": "data/Complaints Report - 2024.xlsx",
|
||||||
|
"sheets": [
|
||||||
|
"January 2024", "February 2024", "March 2024 ", "April 2024",
|
||||||
|
"May 2024", "June 2024", "July 2024", "August 2024",
|
||||||
|
"September 2024", "October 2024", "November 2024", "December 2024",
|
||||||
|
],
|
||||||
|
"importer": "import_historical_complaints",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"year": 2025,
|
||||||
|
"path": "data/Complaints Report - 2025.xlsx",
|
||||||
|
"sheets": ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"],
|
||||||
|
"importer": "import_2025_complaints_basic",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import all historical complaints from 2022-2025 Excel files"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--hospital-code", type=str, default="HH-N")
|
||||||
|
parser.add_argument("--dry-run", action="store_true")
|
||||||
|
parser.add_argument("--year", type=int, help="Import only this year")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
hospital_code = options["hospital_code"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
year_filter = options.get("year")
|
||||||
|
|
||||||
|
total_success = 0
|
||||||
|
total_failed = 0
|
||||||
|
|
||||||
|
for file_info in COMPLAINT_FILES:
|
||||||
|
if year_filter and file_info["year"] != year_filter:
|
||||||
|
continue
|
||||||
|
|
||||||
|
path = file_info["path"]
|
||||||
|
if not os.path.exists(path):
|
||||||
|
self.stderr.write(self.style.ERROR(f"File not found: {path}"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\n{'='*60}"))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"YEAR {file_info['year']}: {path}"))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{'='*60}"))
|
||||||
|
|
||||||
|
for sheet in file_info["sheets"]:
|
||||||
|
self.stdout.write(f"\n Sheet: {sheet}")
|
||||||
|
try:
|
||||||
|
call_command(
|
||||||
|
file_info["importer"],
|
||||||
|
path,
|
||||||
|
sheet=sheet,
|
||||||
|
hospital_code=hospital_code,
|
||||||
|
dry_run=dry_run,
|
||||||
|
stdout=self.stdout,
|
||||||
|
stderr=self.stderr,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(self.style.ERROR(f" FAILED: {e}"))
|
||||||
|
total_failed += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"\n\n{'='*60}"))
|
||||||
|
self.stdout.write(self.style.SUCCESS("ALL IMPORTS COMPLETE"))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{'='*60}"))
|
||||||
@ -18,7 +18,14 @@ from django.core.management.base import BaseCommand, CommandError
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection, Staff
|
from apps.organizations.models import (
|
||||||
|
Department,
|
||||||
|
Hospital,
|
||||||
|
LegacyLocation,
|
||||||
|
LegacyMainSection,
|
||||||
|
LegacySubSection,
|
||||||
|
Staff,
|
||||||
|
)
|
||||||
from apps.complaints.models import Complaint, ComplaintCategory
|
from apps.complaints.models import Complaint, ComplaintCategory
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
@ -30,12 +37,12 @@ from .complaint_taxonomy_mapping import (
|
|||||||
get_mapped_category,
|
get_mapped_category,
|
||||||
is_taxonomy_mapped,
|
is_taxonomy_mapped,
|
||||||
)
|
)
|
||||||
from .complaint_source_mapping import resolve_px_source
|
from .complaint_source_mapping import resolve_px_source, get_complaint_source_type
|
||||||
|
from .arabic_dept_mapping import resolve_section as resolve_dept_and_section
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Default hospital code for all imported complaints
|
DEFAULT_HOSPITAL_CODE = "HH-N"
|
||||||
DEFAULT_HOSPITAL_CODE = "NUZHA"
|
|
||||||
|
|
||||||
# Column mapping: field_name -> column_number (1-based)
|
# Column mapping: field_name -> column_number (1-based)
|
||||||
COLUMN_MAPPING = {
|
COLUMN_MAPPING = {
|
||||||
@ -47,6 +54,18 @@ COLUMN_MAPPING = {
|
|||||||
"sub_dept_name": 8, # القسم الفرعي
|
"sub_dept_name": 8, # القسم الفرعي
|
||||||
"date_received": 9, # تاريخ إستلام الشكوى
|
"date_received": 9, # تاريخ إستلام الشكوى
|
||||||
"data_entry_person": 10, # المدخل (Data Entry Person)
|
"data_entry_person": 10, # المدخل (Data Entry Person)
|
||||||
|
# Timeline columns
|
||||||
|
"form_sent_date": 12, # إرسال نموذج الشكوى (Send Complaint Form)
|
||||||
|
"activated_date": 17, # تفعيل الشكوى (Activate Complaint)
|
||||||
|
"date_sent": 20, # تم ارسال الشكوى (Complaint Sent/Forwarded to Dept)
|
||||||
|
"first_reminder": 24, # First Reminder Sent
|
||||||
|
"second_reminder": 28, # Second Reminder Sent
|
||||||
|
"escalated_date": 32, # Escalated
|
||||||
|
"escalation_reason": 35, # Reason of Escalation
|
||||||
|
"closed_date": 37, # Closed
|
||||||
|
"response_date": 41, # تاريخ الرد (Response Date)
|
||||||
|
"resolved_date": 44, # Resolved
|
||||||
|
# Complaint details
|
||||||
"accused_staff_id": 48, # ID (Employee ID)
|
"accused_staff_id": 48, # ID (Employee ID)
|
||||||
"accused_staff_name": 49, # اسم الشخص المشتكى عليه - ان وجد
|
"accused_staff_name": 49, # اسم الشخص المشتكى عليه - ان وجد
|
||||||
"domain": 50, # Domain
|
"domain": 50, # Domain
|
||||||
@ -57,14 +76,16 @@ COLUMN_MAPPING = {
|
|||||||
"description_en": 55, # محتوى الشكوى (English)
|
"description_en": 55, # محتوى الشكوى (English)
|
||||||
"satisfaction": 56, # Satisfied/Dissatisfied
|
"satisfaction": 56, # Satisfied/Dissatisfied
|
||||||
"rightful_side": 57, # The Rightful Side
|
"rightful_side": 57, # The Rightful Side
|
||||||
# Timeline columns
|
"recommendation": 58, # Recommendation/Action plan
|
||||||
"date_sent": 20, # تم ارسال الشكوى (Complaint Sent/Activated)
|
}
|
||||||
"first_reminder": 24, # First Reminder Sent
|
|
||||||
"second_reminder": 28, # Second Reminder Sent
|
SATISFACTION_MAP = {
|
||||||
"escalated_date": 32, # Escalated
|
"satisfied": "satisfied",
|
||||||
"closed_date": 37, # Closed
|
"dissatisfied": "dissatisfied",
|
||||||
"resolved_date": 44, # Resolved
|
"no response": "no_response",
|
||||||
"response_date": 41, # تاريخ الرد (Response Date - for explanation received)
|
"no_response": "no_response",
|
||||||
|
"neutral": "neutral",
|
||||||
|
"escalated": "escalated",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Month mapping for reference numbers
|
# Month mapping for reference numbers
|
||||||
@ -101,17 +122,18 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Preview without saving to database")
|
parser.add_argument("--dry-run", action="store_true", help="Preview without saving to database")
|
||||||
parser.add_argument("--start-row", type=int, default=3, help="First data row (default: 3, skipping header)")
|
parser.add_argument("--start-row", type=int, default=3, help="First data row (default: 3, skipping header)")
|
||||||
|
parser.add_argument("--hospital-code", type=str, default=DEFAULT_HOSPITAL_CODE, help="Hospital code")
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.excel_file = options["excel_file"]
|
self.excel_file = options["excel_file"]
|
||||||
self.sheet_name = options["sheet"]
|
self.sheet_name = options["sheet"]
|
||||||
self.dry_run = options["dry_run"]
|
self.dry_run = options["dry_run"]
|
||||||
self.start_row = options["start_row"]
|
self.start_row = options["start_row"]
|
||||||
|
self.hospital_code = options["hospital_code"]
|
||||||
|
|
||||||
# Load hospital
|
|
||||||
self.hospital = self._load_hospital()
|
self.hospital = self._load_hospital()
|
||||||
if not self.hospital:
|
if not self.hospital:
|
||||||
raise CommandError(f'Hospital with code "{DEFAULT_HOSPITAL_CODE}" not found')
|
raise CommandError(f'Hospital with code "{self.hospital_code}" not found')
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Using hospital: {self.hospital.name}"))
|
self.stdout.write(self.style.SUCCESS(f"Using hospital: {self.hospital.name}"))
|
||||||
|
|
||||||
@ -145,6 +167,7 @@ class Command(BaseCommand):
|
|||||||
self.unmapped_taxonomy = set()
|
self.unmapped_taxonomy = set()
|
||||||
self.unmatched_locations = set()
|
self.unmatched_locations = set()
|
||||||
self.unmatched_departments = set()
|
self.unmatched_departments = set()
|
||||||
|
self.unmapped_arabic_depts = {}
|
||||||
|
|
||||||
# Cache for used reference numbers to avoid DB queries
|
# Cache for used reference numbers to avoid DB queries
|
||||||
self.used_refs = set()
|
self.used_refs = set()
|
||||||
@ -158,7 +181,7 @@ class Command(BaseCommand):
|
|||||||
def _load_hospital(self) -> Optional[Hospital]:
|
def _load_hospital(self) -> Optional[Hospital]:
|
||||||
"""Load default hospital by code."""
|
"""Load default hospital by code."""
|
||||||
try:
|
try:
|
||||||
return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE)
|
return Hospital.objects.get(code=self.hospital_code)
|
||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -209,6 +232,13 @@ class Command(BaseCommand):
|
|||||||
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
main_section = self._resolve_section(row_data.get("main_dept_name"))
|
||||||
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
subsection = self._resolve_subsection(row_data.get("sub_dept_name"))
|
||||||
|
|
||||||
|
# Resolve department and section FKs from Arabic sub-dept name
|
||||||
|
sub_dept_raw = row_data.get("sub_dept_name")
|
||||||
|
dept, section_obj = resolve_dept_and_section(sub_dept_raw, self.hospital_code)
|
||||||
|
if not dept and sub_dept_raw and isinstance(sub_dept_raw, str) and sub_dept_raw.strip():
|
||||||
|
name = sub_dept_raw.strip()
|
||||||
|
self.unmapped_arabic_depts[name] = self.unmapped_arabic_depts.get(name, 0) + 1
|
||||||
|
|
||||||
# Determine status
|
# Determine status
|
||||||
status = self._determine_status(row_data)
|
status = self._determine_status(row_data)
|
||||||
|
|
||||||
@ -235,6 +265,8 @@ class Command(BaseCommand):
|
|||||||
assigned_to_user = self._get_or_create_data_entry_user(data_entry_person)
|
assigned_to_user = self._get_or_create_data_entry_user(data_entry_person)
|
||||||
|
|
||||||
# Parse timeline dates
|
# Parse timeline dates
|
||||||
|
form_sent_date = self._parse_datetime(row_data.get("form_sent_date"))
|
||||||
|
activated_date = self._parse_datetime(row_data.get("activated_date"))
|
||||||
date_sent = self._parse_datetime(row_data.get("date_sent"))
|
date_sent = self._parse_datetime(row_data.get("date_sent"))
|
||||||
first_reminder = self._parse_datetime(row_data.get("first_reminder"))
|
first_reminder = self._parse_datetime(row_data.get("first_reminder"))
|
||||||
second_reminder = self._parse_datetime(row_data.get("second_reminder"))
|
second_reminder = self._parse_datetime(row_data.get("second_reminder"))
|
||||||
@ -258,15 +290,26 @@ class Command(BaseCommand):
|
|||||||
if rightful_side in ["patient", "hospital", "other"]:
|
if rightful_side in ["patient", "hospital", "other"]:
|
||||||
resolution_outcome = rightful_side
|
resolution_outcome = rightful_side
|
||||||
|
|
||||||
|
# Normalize satisfaction
|
||||||
|
satisfaction_raw = str(row_data.get("satisfaction") or "").lower().strip()
|
||||||
|
satisfaction_val = SATISFACTION_MAP.get(satisfaction_raw, "")
|
||||||
|
|
||||||
|
# Recommendation / action plan
|
||||||
|
recommendation = str(row_data.get("recommendation") or "").strip()
|
||||||
|
|
||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
# Create complaint
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
complaint = Complaint.objects.create(
|
complaint = Complaint.objects.create(
|
||||||
reference_number=ref_num,
|
reference_number=ref_num,
|
||||||
hospital=self.hospital,
|
hospital=self.hospital,
|
||||||
location=location,
|
department=dept,
|
||||||
main_section=main_section,
|
section=section_obj,
|
||||||
subsection=subsection,
|
legacy_location=location,
|
||||||
|
legacy_main_section=main_section,
|
||||||
|
legacy_subsection=subsection,
|
||||||
|
old_location_raw=str(row_data.get("location_name") or "")[:200],
|
||||||
|
old_main_section_raw=str(row_data.get("main_dept_name") or "")[:200],
|
||||||
|
old_subsection_raw=str(row_data.get("sub_dept_name") or "")[:200],
|
||||||
title=self._build_title(row_data),
|
title=self._build_title(row_data),
|
||||||
description=self._build_description(row_data),
|
description=self._build_description(row_data),
|
||||||
patient_name="Unknown",
|
patient_name="Unknown",
|
||||||
@ -282,23 +325,26 @@ class Command(BaseCommand):
|
|||||||
assigned_to=assigned_to_user,
|
assigned_to=assigned_to_user,
|
||||||
resolved_by=assigned_to_user,
|
resolved_by=assigned_to_user,
|
||||||
resolution_outcome=resolution_outcome,
|
resolution_outcome=resolution_outcome,
|
||||||
# Timeline fields
|
form_sent_at=form_sent_date,
|
||||||
activated_at=date_sent,
|
activated_at=activated_date,
|
||||||
|
forwarded_to_dept_at=date_sent,
|
||||||
reminder_sent_at=first_reminder,
|
reminder_sent_at=first_reminder,
|
||||||
second_reminder_sent_at=second_reminder,
|
second_reminder_sent_at=second_reminder,
|
||||||
escalated_at=escalated_date,
|
escalated_at=escalated_date,
|
||||||
closed_at=closed_date,
|
closed_at=closed_date,
|
||||||
resolved_at=resolved_date,
|
resolved_at=resolved_date,
|
||||||
# Explanation tracking
|
|
||||||
explanation_requested=explanation_requested,
|
explanation_requested=explanation_requested,
|
||||||
explanation_requested_at=explanation_requested_at,
|
explanation_requested_at=explanation_requested_at,
|
||||||
explanation_received_at=explanation_received_at,
|
explanation_received_at=explanation_received_at,
|
||||||
due_at=created_at + timedelta(hours=48),
|
due_at=created_at + timedelta(hours=48),
|
||||||
source=px_source,
|
source=px_source,
|
||||||
|
complaint_source_type=get_complaint_source_type(px_source),
|
||||||
|
satisfaction=satisfaction_val,
|
||||||
|
recommendation_action_plan=recommendation,
|
||||||
metadata=self._build_metadata(row_data, ref_num),
|
metadata=self._build_metadata(row_data, ref_num),
|
||||||
|
sent_to_department=bool(dept),
|
||||||
|
sent_to_department_at=created_at if dept else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update created_at to historical date (can't set during create due to auto_now_add)
|
|
||||||
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
|
Complaint.objects.filter(pk=complaint.pk).update(created_at=created_at)
|
||||||
|
|
||||||
self.stats["success"] += 1
|
self.stats["success"] += 1
|
||||||
@ -407,30 +453,30 @@ class Command(BaseCommand):
|
|||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _resolve_location(self, name_ar: str) -> Optional[Location]:
|
def _resolve_location(self, name_ar: str) -> Optional[LegacyLocation]:
|
||||||
"""Resolve location by Arabic name."""
|
"""Resolve location by Arabic name."""
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
location = Location.objects.filter(name_ar=name_ar).first()
|
location = LegacyLocation.objects.filter(name_ar=name_ar).first()
|
||||||
if not location:
|
if not location:
|
||||||
self.unmatched_locations.add(name_ar)
|
self.unmatched_locations.add(name_ar)
|
||||||
return location
|
return location
|
||||||
|
|
||||||
def _resolve_section(self, name_ar: str) -> Optional[MainSection]:
|
def _resolve_section(self, name_ar: str) -> Optional[LegacyMainSection]:
|
||||||
"""Resolve main section/department by Arabic name."""
|
"""Resolve main section/department by Arabic name."""
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
# Try Section model
|
# Try Section model
|
||||||
section = MainSection.objects.filter(name_ar=name_ar).first()
|
section = LegacyMainSection.objects.filter(name_ar=name_ar).first()
|
||||||
if not section:
|
if not section:
|
||||||
self.unmatched_departments.add(name_ar)
|
self.unmatched_departments.add(name_ar)
|
||||||
return section
|
return section
|
||||||
|
|
||||||
def _resolve_subsection(self, name_ar: str) -> Optional[SubSection]:
|
def _resolve_subsection(self, name_ar: str) -> Optional[LegacySubSection]:
|
||||||
"""Resolve subsection by Arabic name."""
|
"""Resolve subsection by Arabic name."""
|
||||||
if not name_ar:
|
if not name_ar:
|
||||||
return None
|
return None
|
||||||
return SubSection.objects.filter(name_ar=name_ar).first()
|
return LegacySubSection.objects.filter(name_ar=name_ar).first()
|
||||||
|
|
||||||
def _resolve_staff_by_id(self, employee_id: str) -> Optional[Staff]:
|
def _resolve_staff_by_id(self, employee_id: str) -> Optional[Staff]:
|
||||||
"""Resolve staff by employee ID."""
|
"""Resolve staff by employee ID."""
|
||||||
@ -630,11 +676,17 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f" - {loc}")
|
self.stdout.write(f" - {loc}")
|
||||||
|
|
||||||
if self.unmatched_departments:
|
if self.unmatched_departments:
|
||||||
self.stdout.write("\n--- Unmatched Departments ---")
|
self.stdout.write("\n--- Unmatched Legacy Departments ---")
|
||||||
self.stdout.write("No MainSection/SubSection found with these name_ar values:")
|
self.stdout.write("No MainSection/SubSection found with these name_ar values:")
|
||||||
for dept in sorted(self.unmatched_departments):
|
for dept in sorted(self.unmatched_departments):
|
||||||
self.stdout.write(f" - {dept}")
|
self.stdout.write(f" - {dept}")
|
||||||
|
|
||||||
|
if self.unmapped_arabic_depts:
|
||||||
|
self.stdout.write("\n--- Unmapped Arabic Sub-Departments ---")
|
||||||
|
sorted_unmapped = sorted(self.unmapped_arabic_depts.items(), key=lambda x: -x[1])
|
||||||
|
for name, count in sorted_unmapped:
|
||||||
|
self.stdout.write(f" {count:4d}x {name}")
|
||||||
|
|
||||||
if self.errors:
|
if self.errors:
|
||||||
self.stdout.write("\n--- Errors ---")
|
self.stdout.write("\n--- Errors ---")
|
||||||
self.stdout.write(f"Total errors: {len(self.errors)}")
|
self.stdout.write(f"Total errors: {len(self.errors)}")
|
||||||
|
|||||||
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0004_legacylocation_legacymainsection_and_more'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -696,8 +696,8 @@ class Migration(migrations.Migration):
|
|||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')),
|
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.department')),
|
||||||
('escalated_ovr_by', models.ForeignKey(blank=True, help_text='User who escalated as OVR', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalated_ovr_complaints', to=settings.AUTH_USER_MODEL)),
|
('escalated_ovr_by', models.ForeignKey(blank=True, help_text='User who escalated as OVR', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='escalated_ovr_complaints', to=settings.AUTH_USER_MODEL)),
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='complaints', to='organizations.hospital')),
|
||||||
('location', models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.location')),
|
('location', models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacylocation')),
|
||||||
('main_section', models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.mainsection')),
|
('main_section', models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacymainsection')),
|
||||||
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
|
('patient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.patient')),
|
||||||
('reopened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopened_complaints', to=settings.AUTH_USER_MODEL)),
|
('reopened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopened_complaints', to=settings.AUTH_USER_MODEL)),
|
||||||
('reopened_from', models.ForeignKey(blank=True, help_text='Original complaint this was reopened from', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopenings', to='complaints.complaint')),
|
('reopened_from', models.ForeignKey(blank=True, help_text='Original complaint this was reopened from', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reopenings', to='complaints.complaint')),
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('complaints', '0002_initial'),
|
('complaints', '0002_initial'),
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0004_legacylocation_legacymainsection_and_more'),
|
||||||
('px_sources', '0001_initial'),
|
('px_sources', '0001_initial'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='complaint',
|
model_name='complaint',
|
||||||
name='subsection',
|
name='subsection',
|
||||||
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.subsection'),
|
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacysubsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='complaintadverseaction',
|
model_name='complaintadverseaction',
|
||||||
@ -275,12 +275,12 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='governmentticket',
|
model_name='governmentticket',
|
||||||
name='location',
|
name='location',
|
||||||
field=models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.location'),
|
field=models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacylocation'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='governmentticket',
|
model_name='governmentticket',
|
||||||
name='main_section',
|
name='main_section',
|
||||||
field=models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.mainsection'),
|
field=models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacymainsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='governmentticket',
|
model_name='governmentticket',
|
||||||
@ -290,7 +290,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='governmentticket',
|
model_name='governmentticket',
|
||||||
name='subsection',
|
name='subsection',
|
||||||
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.subsection'),
|
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacysubsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
@ -345,12 +345,12 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
name='location',
|
name='location',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.location'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacylocation'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
name='main_section',
|
name='main_section',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.mainsection'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacymainsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
@ -375,7 +375,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
name='subsection',
|
name='subsection',
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.subsection'),
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacysubsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='inquiry',
|
model_name='inquiry',
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-13 08:40
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0003_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='cancelled_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='cancelled_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cancelled_complaints', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='partially_resolved_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='partially_resolved_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='partially_resolved_complaints', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-16 16:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0004_add_cancelled_partially_resolved_timestamps'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='sent_to_department',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether this complaint has been sent to the primary department for visibility'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='sent_to_department_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the complaint was sent to the primary department', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='sent',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether this department has been sent the complaint'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the complaint was sent to this department', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='sent_to_department',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether this inquiry has been sent to the primary department for visibility'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='sent_to_department_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the inquiry was sent to the primary department', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_sent_to_department(apps, schema_editor):
|
||||||
|
Complaint = apps.get_model("complaints", "Complaint")
|
||||||
|
Inquiry = apps.get_model("complaints", "Inquiry")
|
||||||
|
CID = apps.get_model("complaints", "ComplaintInvolvedDepartment")
|
||||||
|
|
||||||
|
Complaint.objects.filter(department__isnull=False).update(sent_to_department=True)
|
||||||
|
Complaint.objects.filter(
|
||||||
|
sent_to_department=True,
|
||||||
|
sent_to_department_at__isnull=True,
|
||||||
|
forwarded_to_dept_at__isnull=False,
|
||||||
|
).update(sent_to_department_at=models.F("forwarded_to_dept_at"))
|
||||||
|
Complaint.objects.filter(
|
||||||
|
sent_to_department=True, sent_to_department_at__isnull=True
|
||||||
|
).update(sent_to_department_at=timezone.now())
|
||||||
|
|
||||||
|
Inquiry.objects.filter(department__isnull=False).update(sent_to_department=True)
|
||||||
|
Inquiry.objects.filter(
|
||||||
|
sent_to_department=True,
|
||||||
|
sent_to_department_at__isnull=True,
|
||||||
|
transferred_at__isnull=False,
|
||||||
|
).update(sent_to_department_at=models.F("transferred_at"))
|
||||||
|
Inquiry.objects.filter(
|
||||||
|
sent_to_department=True, sent_to_department_at__isnull=True
|
||||||
|
).update(sent_to_department_at=timezone.now())
|
||||||
|
|
||||||
|
CID.objects.filter(forwarded_at__isnull=False).update(sent=True)
|
||||||
|
CID.objects.filter(sent=True, sent_at__isnull=True).update(
|
||||||
|
sent_at=models.F("forwarded_at")
|
||||||
|
)
|
||||||
|
CID.objects.filter(sent=True, sent_at__isnull=True).update(
|
||||||
|
sent_at=timezone.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_backfill(apps, schema_editor):
|
||||||
|
Complaint = apps.get_model("complaints", "Complaint")
|
||||||
|
Inquiry = apps.get_model("complaints", "Inquiry")
|
||||||
|
CID = apps.get_model("complaints", "ComplaintInvolvedDepartment")
|
||||||
|
|
||||||
|
Complaint.objects.all().update(
|
||||||
|
sent_to_department=False, sent_to_department_at=None
|
||||||
|
)
|
||||||
|
Inquiry.objects.all().update(
|
||||||
|
sent_to_department=False, sent_to_department_at=None
|
||||||
|
)
|
||||||
|
CID.objects.all().update(sent=False, sent_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("complaints", "0005_add_sent_to_department_fields"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(backfill_sent_to_department, reverse_backfill),
|
||||||
|
]
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-17 15:52
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0006_data_sent_to_department_backfill'),
|
||||||
|
('organizations', '0003_alter_department_champion'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='acceptance_notes',
|
||||||
|
field=models.TextField(blank=True, help_text='Notes about the acceptance decision'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='acceptance_status',
|
||||||
|
field=models.CharField(choices=[('pending', 'Pending Review'), ('acceptable', 'Acceptable'), ('not_acceptable', 'Not Acceptable')], default='pending', help_text='Review status of the department response', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='accepted_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the department response was reviewed', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='accepted_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='User who reviewed the department response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_complaint_dept_responses', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='response_notes_ar',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Response (Arabic)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='response_notes_en',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Response (English)'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
index=models.Index(fields=['department', 'acceptance_status'], name='complaints__departm_5ad178_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
88
apps/complaints/migrations/0008_manager_review_workflow.py
Normal file
88
apps/complaints/migrations/0008_manager_review_workflow.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-18 21:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0007_complaintinvolveddepartment_acceptance_notes_and_more'),
|
||||||
|
('organizations', '0003_alter_department_champion'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='manager_review_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('pending', 'Pending Manager Review'), ('approved', 'Manager Approved'), ('rejected', 'Manager Rejected')], default=None, help_text='Department manager review status of the champion response', max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='manager_reviewed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaintinvolveddepartment',
|
||||||
|
name='manager_reviewed_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Department manager who reviewed the champion response', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='manager_reviewed_dept_responses', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DepartmentManagerReview',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('status', models.CharField(choices=[('approved', 'Approved'), ('rejected', 'Rejected')], max_length=20)),
|
||||||
|
('reviewed_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('rejection_reason', models.TextField(blank=True)),
|
||||||
|
('involved_department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manager_reviews', to='complaints.complaintinvolveddepartment')),
|
||||||
|
('reviewed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_manager_reviews', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Department Manager Review',
|
||||||
|
'verbose_name_plural': 'Department Manager Reviews',
|
||||||
|
'ordering': ['-reviewed_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ManagerReviewQuestion',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('text_en', models.TextField(verbose_name='Question Text (English)')),
|
||||||
|
('text_ar', models.TextField(blank=True, verbose_name='Question Text (Arabic)')),
|
||||||
|
('question_type', models.CharField(choices=[('text', 'Short Text'), ('textarea', 'Long Text'), ('yes_no', 'Yes / No'), ('rating', 'Rating (1-5)'), ('multiple_choice', 'Multiple Choice')], default='textarea', max_length=20)),
|
||||||
|
('choices_json', models.JSONField(blank=True, default=list, help_text='List of choices for multiple_choice type, e.g. ["Option A","Option B"]')),
|
||||||
|
('order', models.PositiveIntegerField(default=0)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_manager_review_questions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='manager_review_questions', to='organizations.hospital')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Manager Review Question',
|
||||||
|
'verbose_name_plural': 'Manager Review Questions',
|
||||||
|
'ordering': ['order', 'created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ManagerReviewAnswer',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('text_value', models.TextField(blank=True)),
|
||||||
|
('numeric_value', models.IntegerField(blank=True, null=True)),
|
||||||
|
('review', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='complaints.departmentmanagerreview')),
|
||||||
|
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='complaints.managerreviewquestion')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Manager Review Answer',
|
||||||
|
'verbose_name_plural': 'Manager Review Answers',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-21 21:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_manager_review_status(apps, schema_editor):
|
||||||
|
ComplaintInvolvedDepartment = apps.get_model("complaints", "ComplaintInvolvedDepartment")
|
||||||
|
ComplaintInvolvedDepartment.objects.filter(
|
||||||
|
manager_review_status__isnull=True,
|
||||||
|
response_submitted=True,
|
||||||
|
).update(manager_review_status="pending")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("complaints", "0008_manager_review_workflow"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="complaintinvolveddepartment",
|
||||||
|
name="manager_review_status",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending Manager Review"),
|
||||||
|
("approved", "Manager Approved"),
|
||||||
|
("rejected", "Manager Rejected"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
help_text="Department manager review status of the champion response",
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
backfill_manager_review_status,
|
||||||
|
reverse_code=migrations.RunPython.noop,
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0009_fix_manager_review_default'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='complaint',
|
||||||
|
old_name='location',
|
||||||
|
new_name='legacy_location',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='complaint',
|
||||||
|
old_name='main_section',
|
||||||
|
new_name='legacy_main_section',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='complaint',
|
||||||
|
old_name='subsection',
|
||||||
|
new_name='legacy_subsection',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
old_name='location',
|
||||||
|
new_name='legacy_location',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
old_name='main_section',
|
||||||
|
new_name='legacy_main_section',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
old_name='subsection',
|
||||||
|
new_name='legacy_subsection',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='inquiry',
|
||||||
|
old_name='location',
|
||||||
|
new_name='legacy_location',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='inquiry',
|
||||||
|
old_name='main_section',
|
||||||
|
new_name='legacy_main_section',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='inquiry',
|
||||||
|
old_name='subsection',
|
||||||
|
new_name='legacy_subsection',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0010_remove_complaint_location_and_more'),
|
||||||
|
('organizations', '0008_rename_orgsubsection_to_section_add_champion'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='old_location_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original location Arabic text from complaint', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='old_main_section_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original main section Arabic text from complaint', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='old_subsection_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original subsection Arabic text from complaint', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.Section'),
|
||||||
|
),
|
||||||
|
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='department',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.department'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='old_location_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='old_main_section_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='old_subsection_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.Section'),
|
||||||
|
),
|
||||||
|
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='old_location_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original location Arabic text from inquiry', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='old_main_section_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original main section Arabic text from inquiry', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='old_subsection_raw',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='Original subsection Arabic text from inquiry', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inquiries', to='organizations.Section'),
|
||||||
|
),
|
||||||
|
|
||||||
|
]
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0011_complaint_legacy_location_and_more'),
|
||||||
|
('organizations', '0005_alter_legacylocation_table_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Location (e.g., Riyadh, Jeddah)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section/Department', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Subsection within the section', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
10
apps/complaints/migrations/0013_remove_sub_subsection.py
Normal file
10
apps/complaints/migrations/0013_remove_sub_subsection.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0012_alter_complaint_legacy_location_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-07 04:37
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0013_remove_sub_subsection'),
|
||||||
|
('organizations', '0011_area_department_area'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='area',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='complaints', to='organizations.area'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='location_type',
|
||||||
|
field=models.CharField(blank=True, choices=[('OP', 'Outpatient'), ('IP', 'Inpatient'), ('ER', 'Emergency'), ('GENERAL', 'General')], help_text='Where the incident occurred (OP/IP/ER/GO)', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='area',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.area'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='location_type',
|
||||||
|
field=models.CharField(blank=True, choices=[('OP', 'Outpatient'), ('IP', 'Inpatient'), ('ER', 'Emergency'), ('GENERAL', 'General')], max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='complaints', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='governmentticket',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.PROTECT, related_name='government_tickets', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inquiries', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
apps/complaints/migrations/0015_complaintpdfsummary.py
Normal file
35
apps/complaints/migrations/0015_complaintpdfsummary.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-08 08:45
|
||||||
|
|
||||||
|
import apps.complaints.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0014_complaint_area_complaint_location_type_inquiry_area_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ComplaintPdfSummary',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('lang', models.CharField(default='ar', max_length=5)),
|
||||||
|
('content_summary', models.TextField()),
|
||||||
|
('dept_response_summary', models.TextField()),
|
||||||
|
('file', models.FileField(blank=True, null=True, upload_to=apps.complaints.models.pdf_summary_upload_to)),
|
||||||
|
('file_size', models.IntegerField(default=0)),
|
||||||
|
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pdf_summaries', to='complaints.complaint')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'PDF Summary',
|
||||||
|
'verbose_name_plural': 'PDF Summaries',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
91
apps/complaints/migrations/0016_champion_investigation.py
Normal file
91
apps/complaints/migrations/0016_champion_investigation.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-09 13:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0015_complaintpdfsummary'),
|
||||||
|
('organizations', '0013_add_subsection_model'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='complaint_source_type',
|
||||||
|
field=models.CharField(choices=[('internal', 'Internal'), ('external', 'External')], db_index=True, default='internal', help_text='Source type (Internal = staff-generated, External = patient/public-generated)', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ChampionInvestigation',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('status', models.CharField(choices=[('questions_sent', 'Questions Sent'), ('answers_received', 'Answers Received'), ('reply_submitted', 'Reply Submitted')], default='questions_sent', max_length=20)),
|
||||||
|
('final_reply', models.TextField(blank=True)),
|
||||||
|
('champion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='champion_investigations', to='organizations.staff')),
|
||||||
|
('complaint', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='investigations', to='complaints.complaint')),
|
||||||
|
('explanation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investigation', to='complaints.complaintexplanation')),
|
||||||
|
('involved_department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investigations', to='complaints.complaintinvolveddepartment')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Champion Investigation',
|
||||||
|
'verbose_name_plural': 'Champion Investigations',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InvestigationQuestion',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('question_text', models.TextField()),
|
||||||
|
('order', models.PositiveIntegerField(default=0)),
|
||||||
|
('investigation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='complaints.championinvestigation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Investigation Question',
|
||||||
|
'verbose_name_plural': 'Investigation Questions',
|
||||||
|
'ordering': ['order', 'created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InvestigationResponse',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('token', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||||
|
('is_completed', models.BooleanField(default=False)),
|
||||||
|
('completed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('email_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('sms_sent_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('investigation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='complaints.championinvestigation')),
|
||||||
|
('staff', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='investigation_responses', to='organizations.staff')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Investigation Response',
|
||||||
|
'verbose_name_plural': 'Investigation Responses',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='InvestigationAnswer',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('answer_text', models.TextField(blank=True)),
|
||||||
|
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='complaints.investigationquestion')),
|
||||||
|
('response', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='complaints.investigationresponse')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Investigation Answer',
|
||||||
|
'verbose_name_plural': 'Investigation Answers',
|
||||||
|
'unique_together': {('response', 'question')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-10 20:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_contacted_statuses(apps, schema_editor):
|
||||||
|
Complaint = apps.get_model("complaints", "Complaint")
|
||||||
|
Complaint.objects.filter(status="contacted").update(
|
||||||
|
status="in_progress",
|
||||||
|
patient_contact_status="contacted",
|
||||||
|
)
|
||||||
|
Complaint.objects.filter(status="contacted_no_response").update(
|
||||||
|
status="in_progress",
|
||||||
|
patient_contact_status="contacted_no_response",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0016_champion_investigation'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='patient_contact_status',
|
||||||
|
field=models.CharField(choices=[('not_contacted', 'Not Contacted'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted, No Response')], db_index=True, default='not_contacted', help_text='Tracks whether the patient has been contacted regarding this complaint', max_length=30),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='patient_contact_status_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the patient contact status was last updated', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='patient_contact_status_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='User who last updated the patient contact status', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='patient_contact_updated_complaints', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='complaint',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('partially_resolved', 'Partially Resolved'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('cancelled', 'Cancelled'), ('pending_external', 'Pending External'), ('ovr_pending', 'OVR Pending Approval')], db_index=True, default='open', max_length=25),
|
||||||
|
),
|
||||||
|
migrations.RunPython(migrate_contacted_statuses, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
36
apps/complaints/migrations/0018_simplify_statuses.py
Normal file
36
apps/complaints/migrations/0018_simplify_statuses.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-14 10:56
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0017_add_patient_contact_status'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='contact_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('not_contacted', 'Not Contacted'), ('contacted', 'Contacted'), ('contacted_no_response', 'Contacted - No Response')], db_index=True, default='not_contacted', max_length=25),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='contact_status_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='contact_status_by',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=25),
|
||||||
|
),
|
||||||
|
]
|
||||||
28
apps/complaints/migrations/0019_add_response_token.py
Normal file
28
apps/complaints/migrations/0019_add_response_token.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-14 11:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('complaints', '0018_simplify_statuses'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='response_token',
|
||||||
|
field=models.CharField(blank=True, db_index=True, help_text='One-time token for department response link', max_length=100, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='response_token_sent_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='inquiry',
|
||||||
|
name='response_token_used',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -12,12 +12,14 @@ This module implements the complaint management system that:
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
from apps.core.encryption import EncryptedCharField, compute_national_id_hash, mask_national_id
|
||||||
from apps.core.models import PriorityChoices, SeverityChoices, SoftDeleteModel, TenantModel, TimeStampedModel, UUIDModel
|
from apps.core.models import PriorityChoices, SeverityChoices, SoftDeleteModel, TenantModel, TimeStampedModel, UUIDModel
|
||||||
|
from apps.organizations.models import LocationType
|
||||||
|
|
||||||
|
|
||||||
class ComplaintStatus(models.TextChoices):
|
class ComplaintStatus(models.TextChoices):
|
||||||
@ -29,12 +31,18 @@ class ComplaintStatus(models.TextChoices):
|
|||||||
RESOLVED = "resolved", _("Resolved")
|
RESOLVED = "resolved", _("Resolved")
|
||||||
CLOSED = "closed", _("Closed")
|
CLOSED = "closed", _("Closed")
|
||||||
CANCELLED = "cancelled", _("Cancelled")
|
CANCELLED = "cancelled", _("Cancelled")
|
||||||
CONTACTED = "contacted", _("Contacted")
|
|
||||||
CONTACTED_NO_RESPONSE = "contacted_no_response", _("Contacted, No Response")
|
|
||||||
PENDING_EXTERNAL = "pending_external", _("Pending External")
|
PENDING_EXTERNAL = "pending_external", _("Pending External")
|
||||||
OVR_PENDING = "ovr_pending", _("OVR Pending Approval")
|
OVR_PENDING = "ovr_pending", _("OVR Pending Approval")
|
||||||
|
|
||||||
|
|
||||||
|
class PatientContactStatus(models.TextChoices):
|
||||||
|
"""Patient contact status - tracks whether the patient has been contacted"""
|
||||||
|
|
||||||
|
NOT_CONTACTED = "not_contacted", _("Not Contacted")
|
||||||
|
CONTACTED = "contacted", _("Contacted")
|
||||||
|
CONTACTED_NO_RESPONSE = "contacted_no_response", _("Contacted, No Response")
|
||||||
|
|
||||||
|
|
||||||
class DelayReasonChoices(models.TextChoices):
|
class DelayReasonChoices(models.TextChoices):
|
||||||
"""Delay reason for 72h closure"""
|
"""Delay reason for 72h closure"""
|
||||||
|
|
||||||
@ -264,6 +272,15 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
staff = models.ForeignKey(
|
staff = models.ForeignKey(
|
||||||
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
"organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
||||||
)
|
)
|
||||||
|
location_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=LocationType.choices,
|
||||||
|
blank=True,
|
||||||
|
help_text="Where the incident occurred (OP/IP/ER/GO)",
|
||||||
|
)
|
||||||
|
area = models.ForeignKey(
|
||||||
|
"organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints"
|
||||||
|
)
|
||||||
|
|
||||||
# Complaint details
|
# Complaint details
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
@ -312,30 +329,60 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
help_text="Level 4: Classification",
|
help_text="Level 4: Classification",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Location hierarchy - required fields
|
# Location hierarchy - legacy fields
|
||||||
location = models.ForeignKey(
|
legacy_location = models.ForeignKey(
|
||||||
"organizations.Location",
|
"organizations.LegacyLocation",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="complaints",
|
related_name="complaints",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Location (e.g., Riyadh, Jeddah)",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
main_section = models.ForeignKey(
|
legacy_main_section = models.ForeignKey(
|
||||||
"organizations.MainSection",
|
"organizations.LegacyMainSection",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="complaints",
|
related_name="complaints",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Section/Department",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
subsection = models.ForeignKey(
|
legacy_subsection = models.ForeignKey(
|
||||||
"organizations.SubSection",
|
"organizations.LegacySubSection",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="complaints",
|
related_name="complaints",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Subsection within the section",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# New hierarchy (from 4th Version Excel)
|
||||||
|
section = models.ForeignKey(
|
||||||
|
"organizations.Section",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="complaints",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Section within department",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy raw fields for mapping
|
||||||
|
old_location_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original location Arabic text from complaint",
|
||||||
|
)
|
||||||
|
old_main_section_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original main section Arabic text from complaint",
|
||||||
|
)
|
||||||
|
old_subsection_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original subsection Arabic text from complaint",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Type (complaint vs appreciation)
|
# Type (complaint vs appreciation)
|
||||||
@ -351,7 +398,7 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
complaint_source_type = models.CharField(
|
complaint_source_type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=ComplaintSourceType.choices,
|
choices=ComplaintSourceType.choices,
|
||||||
default=ComplaintSourceType.EXTERNAL,
|
default=ComplaintSourceType.INTERNAL,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="Source type (Internal = staff-generated, External = patient/public-generated)",
|
help_text="Source type (Internal = staff-generated, External = patient/public-generated)",
|
||||||
)
|
)
|
||||||
@ -389,6 +436,25 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
max_length=25, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True
|
max_length=25, choices=ComplaintStatus.choices, default=ComplaintStatus.OPEN, db_index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
patient_contact_status = models.CharField(
|
||||||
|
max_length=30,
|
||||||
|
choices=PatientContactStatus.choices,
|
||||||
|
default=PatientContactStatus.NOT_CONTACTED,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Tracks whether the patient has been contacted regarding this complaint",
|
||||||
|
)
|
||||||
|
patient_contact_status_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="When the patient contact status was last updated"
|
||||||
|
)
|
||||||
|
patient_contact_status_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="patient_contact_updated_complaints",
|
||||||
|
help_text="User who last updated the patient contact status",
|
||||||
|
)
|
||||||
|
|
||||||
# Assignment
|
# Assignment
|
||||||
assigned_to = models.ForeignKey(
|
assigned_to = models.ForeignKey(
|
||||||
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_complaints"
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_complaints"
|
||||||
@ -452,6 +518,16 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_complaints"
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="closed_complaints"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cancelled_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
cancelled_by = models.ForeignKey(
|
||||||
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="cancelled_complaints"
|
||||||
|
)
|
||||||
|
|
||||||
|
partially_resolved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
partially_resolved_by = models.ForeignKey(
|
||||||
|
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="partially_resolved_complaints"
|
||||||
|
)
|
||||||
|
|
||||||
# Reopen
|
# Reopen
|
||||||
reopened_at = models.DateTimeField(null=True, blank=True)
|
reopened_at = models.DateTimeField(null=True, blank=True)
|
||||||
reopened_by = models.ForeignKey(
|
reopened_by = models.ForeignKey(
|
||||||
@ -501,6 +577,12 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
forwarded_to_dept_at = models.DateTimeField(
|
forwarded_to_dept_at = models.DateTimeField(
|
||||||
null=True, blank=True, help_text="When complaint was forwarded to the involved department"
|
null=True, blank=True, help_text="When complaint was forwarded to the involved department"
|
||||||
)
|
)
|
||||||
|
sent_to_department = models.BooleanField(
|
||||||
|
default=False, help_text="Whether this complaint has been sent to the primary department for visibility"
|
||||||
|
)
|
||||||
|
sent_to_department_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="When the complaint was sent to the primary department"
|
||||||
|
)
|
||||||
response_date = models.DateField(null=True, blank=True, help_text="Date when response was received")
|
response_date = models.DateField(null=True, blank=True, help_text="Date when response was received")
|
||||||
|
|
||||||
# Complaint details (Step 1 fields)
|
# Complaint details (Step 1 fields)
|
||||||
@ -538,6 +620,8 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
notes = GenericRelation("core.Note")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -557,6 +641,29 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
return reverse("complaints:complaint_detail", kwargs={"pk": self.pk})
|
return reverse("complaints:complaint_detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
def get_owner(self):
|
||||||
|
"""
|
||||||
|
Returns the owner of this complaint.
|
||||||
|
Cascade: section(champion, supervisor, deputy_supervisor)
|
||||||
|
-> department(champion, deputy_manager, supervisor,
|
||||||
|
deputy_supervisor, manager_2nd, manager_3rd).
|
||||||
|
Returns: Staff instance or None.
|
||||||
|
"""
|
||||||
|
if self.section:
|
||||||
|
for role in ("champion", "supervisor", "deputy_supervisor"):
|
||||||
|
owner = getattr(self.section, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
if self.department:
|
||||||
|
dept = self.department
|
||||||
|
for role in ("champion", "deputy_manager",
|
||||||
|
"supervisor", "deputy_supervisor",
|
||||||
|
"manager_2nd", "manager_3rd"):
|
||||||
|
owner = getattr(dept, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
def get_masked_national_id(self):
|
def get_masked_national_id(self):
|
||||||
return mask_national_id(self.national_id)
|
return mask_national_id(self.national_id)
|
||||||
|
|
||||||
@ -572,12 +679,9 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
# Generate reference number if not set (for all creation methods: form, API, admin)
|
# Generate reference number if not set (for all creation methods: form, API, admin)
|
||||||
if not self.reference_number:
|
if not self.reference_number:
|
||||||
from datetime import datetime
|
from apps.core.reference import generate_reference
|
||||||
import uuid
|
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y%m%d")
|
self.reference_number = generate_reference("CMP", self.hospital)
|
||||||
random_suffix = str(uuid.uuid4().int)[:6]
|
|
||||||
self.reference_number = f"CMP-{today}-{random_suffix}"
|
|
||||||
|
|
||||||
if not self.due_at:
|
if not self.due_at:
|
||||||
self.due_at = self.calculate_sla_due_date()
|
self.due_at = self.calculate_sla_due_date()
|
||||||
@ -596,8 +700,20 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
else:
|
else:
|
||||||
self.national_id_hash = ""
|
self.national_id_hash = ""
|
||||||
|
|
||||||
|
self._sync_department_timestamps()
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def _sync_department_timestamps(self):
|
||||||
|
"""Sync forwarded_to_dept_at and sent_to_department_at — keep both in agreement."""
|
||||||
|
now = timezone.now()
|
||||||
|
if self.sent_to_department and not self.sent_to_department_at:
|
||||||
|
self.sent_to_department_at = now
|
||||||
|
if self.sent_to_department_at and not self.forwarded_to_dept_at:
|
||||||
|
self.forwarded_to_dept_at = self.sent_to_department_at
|
||||||
|
if self.forwarded_to_dept_at and not self.sent_to_department_at:
|
||||||
|
self.sent_to_department_at = self.forwarded_to_dept_at
|
||||||
|
|
||||||
def calculate_sla_due_date(self):
|
def calculate_sla_due_date(self):
|
||||||
"""
|
"""
|
||||||
Calculate SLA due date based on source, severity, and hospital configuration.
|
Calculate SLA due date based on source, severity, and hospital configuration.
|
||||||
@ -700,15 +816,13 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
def is_active_status(self):
|
def is_active_status(self):
|
||||||
"""
|
"""
|
||||||
Check if complaint is in an active status (can be worked on).
|
Check if complaint is in an active status (can be worked on).
|
||||||
Active statuses: OPEN, IN_PROGRESS, PARTIALLY_RESOLVED, CONTACTED, CONTACTED_NO_RESPONSE, PENDING_EXTERNAL
|
Active statuses: OPEN, IN_PROGRESS, PARTIALLY_RESOLVED, PENDING_EXTERNAL
|
||||||
Inactive statuses: RESOLVED, CLOSED, CANCELLED
|
Inactive statuses: RESOLVED, CLOSED, CANCELLED
|
||||||
"""
|
"""
|
||||||
return self.status in [
|
return self.status in [
|
||||||
ComplaintStatus.OPEN,
|
ComplaintStatus.OPEN,
|
||||||
ComplaintStatus.IN_PROGRESS,
|
ComplaintStatus.IN_PROGRESS,
|
||||||
ComplaintStatus.PARTIALLY_RESOLVED,
|
ComplaintStatus.PARTIALLY_RESOLVED,
|
||||||
ComplaintStatus.CONTACTED,
|
|
||||||
ComplaintStatus.CONTACTED_NO_RESPONSE,
|
|
||||||
ComplaintStatus.PENDING_EXTERNAL,
|
ComplaintStatus.PENDING_EXTERNAL,
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -752,13 +866,6 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
ComplaintStatus.RESOLVED: {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
|
ComplaintStatus.RESOLVED: {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
|
||||||
ComplaintStatus.CLOSED: {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
|
ComplaintStatus.CLOSED: {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
|
||||||
ComplaintStatus.CANCELLED: {"label": _("Cancelled"), "slug": "cancelled", "progress": 0, "css": "rose"},
|
ComplaintStatus.CANCELLED: {"label": _("Cancelled"), "slug": "cancelled", "progress": 0, "css": "rose"},
|
||||||
ComplaintStatus.CONTACTED: {"label": _("In Progress"), "slug": "in_progress", "progress": 50, "css": "blue"},
|
|
||||||
ComplaintStatus.CONTACTED_NO_RESPONSE: {
|
|
||||||
"label": _("In Progress"),
|
|
||||||
"slug": "in_progress",
|
|
||||||
"progress": 50,
|
|
||||||
"css": "blue",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -918,6 +1025,17 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
def is_activated(self):
|
def is_activated(self):
|
||||||
return self.activated_at is not None
|
return self.activated_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sent_to_any_department(self):
|
||||||
|
return self.involved_departments.filter(sent=True).exists() or self.sent_to_department
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_departments_responded(self):
|
||||||
|
sent = self.involved_departments.filter(sent=True)
|
||||||
|
if not sent.exists():
|
||||||
|
return self.sent_to_department
|
||||||
|
return not sent.filter(response_submitted=False).exists()
|
||||||
|
|
||||||
def get_tracking_url(self):
|
def get_tracking_url(self):
|
||||||
"""
|
"""
|
||||||
Get the public tracking URL for this complaint.
|
Get the public tracking URL for this complaint.
|
||||||
@ -957,6 +1075,29 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
|
|||||||
return f"{self.complaint} - {self.filename}"
|
return f"{self.complaint} - {self.filename}"
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_summary_upload_to(instance, filename):
|
||||||
|
return f"pdf_summaries/complaints/{instance.complaint_id}_ar.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class ComplaintPdfSummary(UUIDModel, TimeStampedModel):
|
||||||
|
"""Persisted PDF summary with AI-generated text and generated file."""
|
||||||
|
|
||||||
|
complaint = models.ForeignKey(Complaint, on_delete=models.CASCADE, related_name="pdf_summaries")
|
||||||
|
lang = models.CharField(max_length=5, default="ar")
|
||||||
|
content_summary = models.TextField()
|
||||||
|
dept_response_summary = models.TextField()
|
||||||
|
file = models.FileField(upload_to=pdf_summary_upload_to, blank=True, null=True)
|
||||||
|
file_size = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
verbose_name = "PDF Summary"
|
||||||
|
verbose_name_plural = "PDF Summaries"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"PDF Summary - {self.complaint.reference_number}"
|
||||||
|
|
||||||
|
|
||||||
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
class ComplaintUpdate(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
Complaint update/timeline entry.
|
Complaint update/timeline entry.
|
||||||
@ -1436,28 +1577,69 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
department = models.ForeignKey(
|
department = models.ForeignKey(
|
||||||
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries"
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries"
|
||||||
)
|
)
|
||||||
|
location_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=LocationType.choices,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
area = models.ForeignKey(
|
||||||
|
"organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="inquiries"
|
||||||
|
)
|
||||||
|
|
||||||
# Location
|
# Location - legacy fields
|
||||||
location = models.ForeignKey(
|
legacy_location = models.ForeignKey(
|
||||||
"organizations.Location",
|
"organizations.LegacyLocation",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="inquiries",
|
related_name="inquiries",
|
||||||
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
main_section = models.ForeignKey(
|
legacy_main_section = models.ForeignKey(
|
||||||
"organizations.MainSection",
|
"organizations.LegacyMainSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="inquiries",
|
related_name="inquiries",
|
||||||
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
subsection = models.ForeignKey(
|
legacy_subsection = models.ForeignKey(
|
||||||
"organizations.SubSection",
|
"organizations.LegacySubSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="inquiries",
|
related_name="inquiries",
|
||||||
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# New hierarchy (from 4th Version Excel)
|
||||||
|
section = models.ForeignKey(
|
||||||
|
"organizations.Section",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="inquiries",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Section within department",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy raw fields for mapping
|
||||||
|
old_location_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original location Arabic text from inquiry",
|
||||||
|
)
|
||||||
|
old_main_section_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original main section Arabic text from inquiry",
|
||||||
|
)
|
||||||
|
old_subsection_raw = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Original subsection Arabic text from inquiry",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reference number
|
# Reference number
|
||||||
@ -1547,13 +1729,31 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
("in_progress", _("In Progress")),
|
("in_progress", _("In Progress")),
|
||||||
("resolved", _("Resolved")),
|
("resolved", _("Resolved")),
|
||||||
("closed", _("Closed")),
|
("closed", _("Closed")),
|
||||||
("contacted", _("Contacted")),
|
|
||||||
("contacted_no_response", _("Contacted, No Response")),
|
|
||||||
],
|
],
|
||||||
default="open",
|
default="open",
|
||||||
db_index=True,
|
db_index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
contact_status = models.CharField(
|
||||||
|
max_length=25,
|
||||||
|
choices=[
|
||||||
|
("not_contacted", _("Not Contacted")),
|
||||||
|
("contacted", _("Contacted")),
|
||||||
|
("contacted_no_response", _("Contacted - No Response")),
|
||||||
|
],
|
||||||
|
default="not_contacted",
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
)
|
||||||
|
contact_status_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
contact_status_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="+",
|
||||||
|
)
|
||||||
|
|
||||||
# Creator tracking
|
# Creator tracking
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
"accounts.User",
|
"accounts.User",
|
||||||
@ -1615,6 +1815,12 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
transferred_at = models.DateTimeField(
|
transferred_at = models.DateTimeField(
|
||||||
null=True, blank=True, db_index=True, help_text="When the inquiry was transferred to a department"
|
null=True, blank=True, db_index=True, help_text="When the inquiry was transferred to a department"
|
||||||
)
|
)
|
||||||
|
sent_to_department = models.BooleanField(
|
||||||
|
default=False, help_text="Whether this inquiry has been sent to the primary department for visibility"
|
||||||
|
)
|
||||||
|
sent_to_department_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="When the inquiry was sent to the primary department"
|
||||||
|
)
|
||||||
transferred_by = models.ForeignKey(
|
transferred_by = models.ForeignKey(
|
||||||
"accounts.User",
|
"accounts.User",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
@ -1655,6 +1861,14 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
related_name="department_inquiry_responses",
|
related_name="department_inquiry_responses",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Token-based department response
|
||||||
|
response_token = models.CharField(
|
||||||
|
max_length=100, blank=True, null=True, unique=True, db_index=True,
|
||||||
|
help_text="One-time token for department response link",
|
||||||
|
)
|
||||||
|
response_token_used = models.BooleanField(default=False)
|
||||||
|
response_token_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
# Department response SLA tracking
|
# Department response SLA tracking
|
||||||
dept_response_sla_due_at = models.DateTimeField(
|
dept_response_sla_due_at = models.DateTimeField(
|
||||||
null=True, blank=True, db_index=True,
|
null=True, blank=True, db_index=True,
|
||||||
@ -1786,6 +2000,8 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
null=True, blank=True, help_text="When reminder was sent for follow-up"
|
null=True, blank=True, help_text="When reminder was sent for follow-up"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notes = GenericRelation("core.Note")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
verbose_name_plural = "Inquiries"
|
verbose_name_plural = "Inquiries"
|
||||||
@ -1797,12 +2013,9 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.reference_number:
|
if not self.reference_number:
|
||||||
from datetime import datetime
|
from apps.core.reference import generate_reference
|
||||||
import uuid
|
|
||||||
|
|
||||||
today = datetime.now().strftime("%Y%m%d")
|
self.reference_number = generate_reference("INQ", self.hospital)
|
||||||
random_suffix = str(uuid.uuid4().int)[:6]
|
|
||||||
self.reference_number = f"INQ-{today}-{random_suffix}"
|
|
||||||
|
|
||||||
if not self.due_at:
|
if not self.due_at:
|
||||||
sla_config = self.get_sla_config()
|
sla_config = self.get_sla_config()
|
||||||
@ -1834,6 +2047,29 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
return reverse("inquiries:inquiry_detail", kwargs={"pk": self.pk})
|
return reverse("inquiries:inquiry_detail", kwargs={"pk": self.pk})
|
||||||
|
|
||||||
|
def get_owner(self):
|
||||||
|
"""
|
||||||
|
Returns the owner of this inquiry.
|
||||||
|
Cascade: section(champion, supervisor, deputy_supervisor)
|
||||||
|
-> department(champion, deputy_manager, supervisor,
|
||||||
|
deputy_supervisor, manager_2nd, manager_3rd).
|
||||||
|
Returns: Staff instance or None.
|
||||||
|
"""
|
||||||
|
if self.section:
|
||||||
|
for role in ("champion", "supervisor", "deputy_supervisor"):
|
||||||
|
owner = getattr(self.section, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
if self.department:
|
||||||
|
dept = self.department
|
||||||
|
for role in ("champion", "deputy_manager",
|
||||||
|
"supervisor", "deputy_supervisor",
|
||||||
|
"manager_2nd", "manager_3rd"):
|
||||||
|
owner = getattr(dept, role, None)
|
||||||
|
if owner:
|
||||||
|
return owner
|
||||||
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def short_description_en(self):
|
def short_description_en(self):
|
||||||
if self.metadata and "ai_analysis" in self.metadata:
|
if self.metadata and "ai_analysis" in self.metadata:
|
||||||
@ -1911,7 +2147,7 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
"""
|
"""
|
||||||
Check if inquiry is in an active status (can be worked on).
|
Check if inquiry is in an active status (can be worked on).
|
||||||
Active statuses: open, in_progress
|
Active statuses: open, in_progress
|
||||||
Inactive statuses: resolved, closed, contacted, contacted_no_response
|
Inactive statuses: resolved, closed
|
||||||
"""
|
"""
|
||||||
return self.status in ["open", "in_progress"]
|
return self.status in ["open", "in_progress"]
|
||||||
|
|
||||||
@ -2154,6 +2390,17 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel):
|
|||||||
"""Count of explanation attachments"""
|
"""Count of explanation attachments"""
|
||||||
return self.attachments.count()
|
return self.attachments.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def linked_involved_department(self):
|
||||||
|
"""Find the linked ComplaintInvolvedDepartment for this explanation."""
|
||||||
|
if not self.staff or not self.staff.department:
|
||||||
|
return None
|
||||||
|
return ComplaintInvolvedDepartment.objects.filter(
|
||||||
|
complaint=self.complaint,
|
||||||
|
department=self.staff.department,
|
||||||
|
sent=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
def get_token(self):
|
def get_token(self):
|
||||||
"""Return the access token"""
|
"""Return the access token"""
|
||||||
return self.token
|
return self.token
|
||||||
@ -2449,8 +2696,35 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
response_notes = models.TextField(blank=True, help_text="Department's response/feedback on the complaint")
|
response_notes = models.TextField(blank=True, help_text="Department's response/feedback on the complaint")
|
||||||
|
|
||||||
|
response_notes_en = models.TextField(blank=True, verbose_name="Response (English)")
|
||||||
|
response_notes_ar = models.TextField(blank=True, verbose_name="Response (Arabic)")
|
||||||
|
|
||||||
|
# Acceptance review
|
||||||
|
acceptance_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("pending", _("Pending Review")),
|
||||||
|
("acceptable", _("Acceptable")),
|
||||||
|
("not_acceptable", _("Not Acceptable")),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
help_text="Review status of the department response",
|
||||||
|
)
|
||||||
|
accepted_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="reviewed_complaint_dept_responses",
|
||||||
|
help_text="User who reviewed the department response",
|
||||||
|
)
|
||||||
|
accepted_at = models.DateTimeField(null=True, blank=True, help_text="When the department response was reviewed")
|
||||||
|
acceptance_notes = models.TextField(blank=True, help_text="Notes about the acceptance decision")
|
||||||
|
|
||||||
# Reminder and delay tracking (Step 1 fields)
|
# Reminder and delay tracking (Step 1 fields)
|
||||||
forwarded_at = models.DateTimeField(null=True, blank=True, help_text="When complaint was sent to this department")
|
forwarded_at = models.DateTimeField(null=True, blank=True, help_text="When complaint was sent to this department")
|
||||||
|
sent = models.BooleanField(default=False, help_text="Whether this department has been sent the complaint")
|
||||||
|
sent_at = models.DateTimeField(null=True, blank=True, help_text="When the complaint was sent to this department")
|
||||||
first_reminder_sent_at = models.DateTimeField(
|
first_reminder_sent_at = models.DateTimeField(
|
||||||
null=True, blank=True, help_text="When first reminder was sent to this department"
|
null=True, blank=True, help_text="When first reminder was sent to this department"
|
||||||
)
|
)
|
||||||
@ -2460,6 +2734,28 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
|||||||
delay_reason = models.TextField(blank=True, help_text="Reason for department delay in response")
|
delay_reason = models.TextField(blank=True, help_text="Reason for department delay in response")
|
||||||
delayed_person = models.CharField(max_length=200, blank=True, help_text="Name of person responsible for delay")
|
delayed_person = models.CharField(max_length=200, blank=True, help_text="Name of person responsible for delay")
|
||||||
|
|
||||||
|
manager_review_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("pending", _("Pending Manager Review")),
|
||||||
|
("approved", _("Manager Approved")),
|
||||||
|
("rejected", _("Manager Rejected")),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
default="pending",
|
||||||
|
help_text="Department manager review status of the champion response",
|
||||||
|
)
|
||||||
|
manager_reviewed_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="manager_reviewed_dept_responses",
|
||||||
|
help_text="Department manager who reviewed the champion response",
|
||||||
|
)
|
||||||
|
manager_reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-is_primary", "-created_at"]
|
ordering = ["-is_primary", "-created_at"]
|
||||||
verbose_name = "Complaint Involved Department"
|
verbose_name = "Complaint Involved Department"
|
||||||
@ -2469,6 +2765,7 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
|||||||
models.Index(fields=["complaint", "role"]),
|
models.Index(fields=["complaint", "role"]),
|
||||||
models.Index(fields=["department", "response_submitted"]),
|
models.Index(fields=["department", "response_submitted"]),
|
||||||
models.Index(fields=["department", "forwarded_at"]),
|
models.Index(fields=["department", "forwarded_at"]),
|
||||||
|
models.Index(fields=["department", "acceptance_status"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@ -2485,6 +2782,20 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
|
|||||||
).update(is_primary=False)
|
).update(is_primary=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sla_remaining(self):
|
||||||
|
"""Hours remaining for this department to respond (based on ExplanationSLAConfig)."""
|
||||||
|
if self.response_submitted or not self.sent_at:
|
||||||
|
return None
|
||||||
|
from apps.complaints.tasks import get_explanation_sla_config
|
||||||
|
sla_config = get_explanation_sla_config(self.complaint.hospital)
|
||||||
|
sla_hours = sla_config.response_hours if sla_config else 48
|
||||||
|
due_at = self.sent_at + timedelta(hours=sla_hours)
|
||||||
|
remaining = due_at - timezone.now()
|
||||||
|
if remaining.total_seconds() <= 0:
|
||||||
|
return timedelta(0)
|
||||||
|
return remaining
|
||||||
|
|
||||||
|
|
||||||
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
|
||||||
"""
|
"""
|
||||||
@ -3223,32 +3534,57 @@ class GovernmentTicket(UUIDModel, TimeStampedModel):
|
|||||||
national_id = models.CharField(max_length=20, blank=True)
|
national_id = models.CharField(max_length=20, blank=True)
|
||||||
contact_number = models.CharField(max_length=20, blank=True)
|
contact_number = models.CharField(max_length=20, blank=True)
|
||||||
|
|
||||||
# Location hierarchy
|
# Location hierarchy - legacy fields
|
||||||
location = models.ForeignKey(
|
legacy_location = models.ForeignKey(
|
||||||
"organizations.Location",
|
"organizations.LegacyLocation",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="government_tickets",
|
related_name="government_tickets",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_("Location (e.g., Riyadh, Jeddah)"),
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
main_section = models.ForeignKey(
|
legacy_main_section = models.ForeignKey(
|
||||||
"organizations.MainSection",
|
"organizations.LegacyMainSection",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="government_tickets",
|
related_name="government_tickets",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_("Section/Department"),
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
subsection = models.ForeignKey(
|
legacy_subsection = models.ForeignKey(
|
||||||
"organizations.SubSection",
|
"organizations.LegacySubSection",
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="government_tickets",
|
related_name="government_tickets",
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text=_("Subsection within the section"),
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Department (new hierarchy)
|
||||||
|
department = models.ForeignKey(
|
||||||
|
"organizations.Department",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="government_tickets",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Department",
|
||||||
|
)
|
||||||
|
|
||||||
|
# New hierarchy
|
||||||
|
section = models.ForeignKey(
|
||||||
|
"organizations.Section",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="government_tickets",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Section within department",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy raw fields
|
||||||
|
old_location_raw = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
old_main_section_raw = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
old_subsection_raw = models.CharField(max_length=200, blank=True, db_index=True)
|
||||||
|
|
||||||
# Dates
|
# Dates
|
||||||
received_date = models.DateTimeField(help_text=_("Date/time the ticket was received from source"))
|
received_date = models.DateTimeField(help_text=_("Date/time the ticket was received from source"))
|
||||||
|
|
||||||
@ -3282,3 +3618,239 @@ class GovernmentTicket(UUIDModel, TimeStampedModel):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.ticket_number} - {self.complainant_name}"
|
return f"{self.ticket_number} - {self.complainant_name}"
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerReviewQuestionType(models.TextChoices):
|
||||||
|
TEXT = "text", _("Short Text")
|
||||||
|
TEXTAREA = "textarea", _("Long Text")
|
||||||
|
YES_NO = "yes_no", _("Yes / No")
|
||||||
|
RATING = "rating", _("Rating (1-5)")
|
||||||
|
MULTIPLE_CHOICE = "multiple_choice", _("Multiple Choice")
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerReviewQuestion(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Configurable questions that PX-Admin creates per hospital.
|
||||||
|
Shown to the Department Manager when reviewing a champion's response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
hospital = models.ForeignKey(
|
||||||
|
"organizations.Hospital",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="manager_review_questions",
|
||||||
|
)
|
||||||
|
text_en = models.TextField(verbose_name="Question Text (English)")
|
||||||
|
text_ar = models.TextField(blank=True, verbose_name="Question Text (Arabic)")
|
||||||
|
question_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ManagerReviewQuestionType.choices,
|
||||||
|
default=ManagerReviewQuestionType.TEXTAREA,
|
||||||
|
)
|
||||||
|
choices_json = models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text='List of choices for multiple_choice type, e.g. ["Option A","Option B"]',
|
||||||
|
)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="created_manager_review_questions",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["order", "created_at"]
|
||||||
|
verbose_name = "Manager Review Question"
|
||||||
|
verbose_name_plural = "Manager Review Questions"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.text_en[:80]
|
||||||
|
|
||||||
|
def get_localized_text(self):
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
if get_language() == "ar" and self.text_ar:
|
||||||
|
return self.text_ar
|
||||||
|
return self.text_en
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentManagerReview(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Records the Department Manager's review of a champion's response.
|
||||||
|
One review per involved-department response cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
involved_department = models.ForeignKey(
|
||||||
|
ComplaintInvolvedDepartment,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="manager_reviews",
|
||||||
|
)
|
||||||
|
reviewed_by = models.ForeignKey(
|
||||||
|
"accounts.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name="dept_manager_reviews",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("approved", _("Approved")),
|
||||||
|
("rejected", _("Rejected")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
reviewed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
rejection_reason = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-reviewed_at"]
|
||||||
|
verbose_name = "Department Manager Review"
|
||||||
|
verbose_name_plural = "Department Manager Reviews"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"ManagerReview({self.involved_department}, {self.status})"
|
||||||
|
|
||||||
|
|
||||||
|
class ManagerReviewAnswer(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Individual answer to a ManagerReviewQuestion within a DepartmentManagerReview.
|
||||||
|
"""
|
||||||
|
|
||||||
|
review = models.ForeignKey(
|
||||||
|
DepartmentManagerReview,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="answers",
|
||||||
|
)
|
||||||
|
question = models.ForeignKey(
|
||||||
|
ManagerReviewQuestion,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="answers",
|
||||||
|
)
|
||||||
|
text_value = models.TextField(blank=True)
|
||||||
|
numeric_value = models.IntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Manager Review Answer"
|
||||||
|
verbose_name_plural = "Manager Review Answers"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Answer({self.question.text_en[:40]}): {self.text_value[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationStatus(models.TextChoices):
|
||||||
|
QUESTIONS_SENT = "questions_sent", "Questions Sent"
|
||||||
|
ANSWERS_RECEIVED = "answers_received", "Answers Received"
|
||||||
|
REPLY_SUBMITTED = "reply_submitted", "Reply Submitted"
|
||||||
|
|
||||||
|
|
||||||
|
class ChampionInvestigation(UUIDModel, TimeStampedModel):
|
||||||
|
complaint = models.ForeignKey(
|
||||||
|
Complaint,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="investigations",
|
||||||
|
)
|
||||||
|
champion = models.ForeignKey(
|
||||||
|
"organizations.Staff",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="champion_investigations",
|
||||||
|
)
|
||||||
|
involved_department = models.ForeignKey(
|
||||||
|
"ComplaintInvolvedDepartment",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="investigations",
|
||||||
|
)
|
||||||
|
explanation = models.ForeignKey(
|
||||||
|
"ComplaintExplanation",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="investigation",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=InvestigationStatus.choices,
|
||||||
|
default=InvestigationStatus.QUESTIONS_SENT,
|
||||||
|
)
|
||||||
|
final_reply = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Champion Investigation"
|
||||||
|
verbose_name_plural = "Champion Investigations"
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Investigation({self.complaint.reference_number}) - {self.status}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_responses_received(self):
|
||||||
|
return self.responses.exists() and not self.responses.filter(is_completed=False).exists()
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationQuestion(UUIDModel, TimeStampedModel):
|
||||||
|
investigation = models.ForeignKey(
|
||||||
|
ChampionInvestigation,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="questions",
|
||||||
|
)
|
||||||
|
question_text = models.TextField()
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Investigation Question"
|
||||||
|
verbose_name_plural = "Investigation Questions"
|
||||||
|
ordering = ["order", "created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Q{self.order}: {self.question_text[:80]}"
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationResponse(UUIDModel, TimeStampedModel):
|
||||||
|
investigation = models.ForeignKey(
|
||||||
|
ChampionInvestigation,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="responses",
|
||||||
|
)
|
||||||
|
staff = models.ForeignKey(
|
||||||
|
"organizations.Staff",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="investigation_responses",
|
||||||
|
)
|
||||||
|
token = models.CharField(max_length=64, unique=True, db_index=True)
|
||||||
|
is_completed = models.BooleanField(default=False)
|
||||||
|
completed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
sms_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Investigation Response"
|
||||||
|
verbose_name_plural = "Investigation Responses"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Response({self.staff.get_full_name()}) - {'Done' if self.is_completed else 'Pending'}"
|
||||||
|
|
||||||
|
|
||||||
|
class InvestigationAnswer(UUIDModel, TimeStampedModel):
|
||||||
|
response = models.ForeignKey(
|
||||||
|
InvestigationResponse,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="answers",
|
||||||
|
)
|
||||||
|
question = models.ForeignKey(
|
||||||
|
InvestigationQuestion,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="answers",
|
||||||
|
)
|
||||||
|
answer_text = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Investigation Answer"
|
||||||
|
verbose_name_plural = "Investigation Answers"
|
||||||
|
unique_together = [("response", "question")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"A: {self.answer_text[:80]}"
|
||||||
|
|||||||
@ -210,6 +210,8 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
|||||||
"source_name",
|
"source_name",
|
||||||
"source_code",
|
"source_code",
|
||||||
"status",
|
"status",
|
||||||
|
"patient_contact_status",
|
||||||
|
"patient_contact_status_at",
|
||||||
"created_by",
|
"created_by",
|
||||||
"created_by_name",
|
"created_by_name",
|
||||||
"assigned_to",
|
"assigned_to",
|
||||||
@ -221,9 +223,11 @@ class ComplaintSerializer(serializers.ModelSerializer):
|
|||||||
"sla_status",
|
"sla_status",
|
||||||
"reminder_sent_at",
|
"reminder_sent_at",
|
||||||
"escalated_at",
|
"escalated_at",
|
||||||
"location",
|
"legacy_location",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
|
"section",
|
||||||
|
|
||||||
"resolution",
|
"resolution",
|
||||||
"resolution_category",
|
"resolution_category",
|
||||||
"resolution_outcome",
|
"resolution_outcome",
|
||||||
@ -476,6 +480,7 @@ class ComplaintListSerializer(serializers.ModelSerializer):
|
|||||||
"complaint_source_type_display",
|
"complaint_source_type_display",
|
||||||
"source_name",
|
"source_name",
|
||||||
"status",
|
"status",
|
||||||
|
"patient_contact_status",
|
||||||
"assigned_to_name",
|
"assigned_to_name",
|
||||||
"assigned_at",
|
"assigned_at",
|
||||||
"due_at",
|
"due_at",
|
||||||
@ -545,9 +550,11 @@ class InquirySerializer(serializers.ModelSerializer):
|
|||||||
"ai_brief_ar",
|
"ai_brief_ar",
|
||||||
"source",
|
"source",
|
||||||
"status",
|
"status",
|
||||||
"location",
|
"legacy_location",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
|
"section",
|
||||||
|
|
||||||
"is_outgoing",
|
"is_outgoing",
|
||||||
"outgoing_department",
|
"outgoing_department",
|
||||||
"is_straightforward",
|
"is_straightforward",
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.core.services import AuditService
|
from apps.core.services import AuditService
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService, get_email_header_html
|
||||||
from apps.organizations.models import Department
|
from apps.organizations.models import Department
|
||||||
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
|
from apps.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_explanation_sla_config(hospital):
|
||||||
|
"""Get explanation SLA configuration for a hospital."""
|
||||||
|
from apps.complaints.models import ExplanationSLAConfig
|
||||||
|
try:
|
||||||
|
return ExplanationSLAConfig.objects.get(hospital=hospital, is_active=True)
|
||||||
|
except ExplanationSLAConfig.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ComplaintServiceError(Exception):
|
class ComplaintServiceError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -256,15 +267,14 @@ class ComplaintService:
|
|||||||
|
|
||||||
# Valid status transitions for lifecycle enforcement
|
# Valid status transitions for lifecycle enforcement
|
||||||
VALID_STATUS_TRANSITIONS = {
|
VALID_STATUS_TRANSITIONS = {
|
||||||
"open": ["in_progress", "cancelled", "contacted", "contacted_no_response"],
|
"open": ["in_progress", "cancelled"],
|
||||||
"in_progress": ["partially_resolved", "resolved", "cancelled", "contacted", "contacted_no_response", "pending_external"],
|
"in_progress": ["partially_resolved", "resolved", "cancelled", "pending_external", "ovr_pending"],
|
||||||
"partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"],
|
"partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"],
|
||||||
"resolved": ["closed", "in_progress"],
|
"resolved": ["closed", "in_progress"],
|
||||||
"closed": ["in_progress"],
|
"closed": ["in_progress"],
|
||||||
"cancelled": ["open", "in_progress"],
|
"cancelled": ["open", "in_progress"],
|
||||||
"contacted": ["open", "in_progress", "contacted_no_response", "cancelled", "pending_external"],
|
|
||||||
"contacted_no_response": ["open", "in_progress", "cancelled", "pending_external"],
|
|
||||||
"pending_external": ["resolved", "in_progress", "cancelled", "closed"],
|
"pending_external": ["resolved", "in_progress", "cancelled", "closed"],
|
||||||
|
"ovr_pending": ["in_progress", "resolved", "cancelled"],
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -294,9 +304,6 @@ class ComplaintService:
|
|||||||
classification=complaint.classification,
|
classification=complaint.classification,
|
||||||
subcategory_obj=complaint.subcategory_obj,
|
subcategory_obj=complaint.subcategory_obj,
|
||||||
classification_obj=complaint.classification_obj,
|
classification_obj=complaint.classification_obj,
|
||||||
location=complaint.location,
|
|
||||||
main_section=complaint.main_section,
|
|
||||||
subsection=complaint.subsection,
|
|
||||||
complaint_type=complaint.complaint_type,
|
complaint_type=complaint.complaint_type,
|
||||||
complaint_source_type=complaint.complaint_source_type,
|
complaint_source_type=complaint.complaint_source_type,
|
||||||
priority=complaint.priority,
|
priority=complaint.priority,
|
||||||
@ -409,6 +416,14 @@ class ComplaintService:
|
|||||||
complaint.pending_external_set_at = timezone.now()
|
complaint.pending_external_set_at = timezone.now()
|
||||||
complaint.was_pending_external = True
|
complaint.was_pending_external = True
|
||||||
|
|
||||||
|
elif new_status == ComplaintStatus.CANCELLED or new_status == "cancelled":
|
||||||
|
complaint.cancelled_at = timezone.now()
|
||||||
|
complaint.cancelled_by = changed_by
|
||||||
|
|
||||||
|
elif new_status == ComplaintStatus.PARTIALLY_RESOLVED or new_status == "partially_resolved":
|
||||||
|
complaint.partially_resolved_at = timezone.now()
|
||||||
|
complaint.partially_resolved_by = changed_by
|
||||||
|
|
||||||
complaint.save()
|
complaint.save()
|
||||||
|
|
||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
@ -561,6 +576,7 @@ class ComplaintService:
|
|||||||
requested_by,
|
requested_by,
|
||||||
domain,
|
domain,
|
||||||
request=None,
|
request=None,
|
||||||
|
contact_person_map=None,
|
||||||
):
|
):
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
@ -578,12 +594,34 @@ class ComplaintService:
|
|||||||
if dept_id not in selected_dept_ids:
|
if dept_id not in selected_dept_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
champion = dept_info.get("champion")
|
champion = None
|
||||||
champion_email = dept_info.get("champion_email")
|
champion_email = None
|
||||||
|
champion_display = None
|
||||||
|
|
||||||
|
if contact_person_map and dept_id in contact_person_map:
|
||||||
|
cp_id = contact_person_map[dept_id]
|
||||||
|
dept_obj = Department.objects.filter(id=dept_id).first()
|
||||||
|
if dept_obj:
|
||||||
|
cinfo = dept_obj.is_valid_contact_person(cp_id)
|
||||||
|
if cinfo:
|
||||||
|
champion = cinfo["staff"]
|
||||||
|
champion_email = cinfo["email"]
|
||||||
|
champion_display = f"{cinfo['name']} ({cinfo['role_label']})"
|
||||||
|
|
||||||
if not champion or not champion_email:
|
if not champion or not champion_email:
|
||||||
skipped_no_email += 1
|
champion = dept_info.get("champion")
|
||||||
continue
|
champion_email = dept_info.get("champion_email")
|
||||||
|
|
||||||
|
if not champion or not champion_email:
|
||||||
|
dept_obj = Department.objects.filter(id=dept_id).select_related("manager", "manager__staff_profile").first()
|
||||||
|
if dept_obj and dept_obj.manager:
|
||||||
|
manager_staff = getattr(dept_obj.manager, 'staff_profile', None)
|
||||||
|
if manager_staff:
|
||||||
|
champion = manager_staff
|
||||||
|
champion_email = manager_staff.email or dept_obj.manager.email
|
||||||
|
if not champion:
|
||||||
|
skipped_no_email += 1
|
||||||
|
continue
|
||||||
|
|
||||||
staff_names = [s["staff_name"] for s in dept_info["staff_list"]]
|
staff_names = [s["staff_name"] for s in dept_info["staff_list"]]
|
||||||
|
|
||||||
@ -604,13 +642,13 @@ class ComplaintService:
|
|||||||
|
|
||||||
champion_link = f"https://{domain}/complaints/{complaint.id}/explain/{champion_token}/"
|
champion_link = f"https://{domain}/complaints/{complaint.id}/explain/{champion_token}/"
|
||||||
champion_subject = f"Explanation Request - Complaint #{complaint.reference_number}"
|
champion_subject = f"Explanation Request - Complaint #{complaint.reference_number}"
|
||||||
champion_display = dept_info.get("champion_name", str(champion))
|
champion_display = champion_display or dept_info.get("champion_name", str(champion))
|
||||||
|
|
||||||
staff_list_text = "\n".join(f" - {n}" for n in staff_names)
|
staff_list_text = "\n".join(f" - {n}" for n in staff_names)
|
||||||
|
|
||||||
champion_email_body = f"""Dear {champion_display},
|
champion_email_body = f"""Dear {champion.get_full_name()},
|
||||||
|
|
||||||
As the department champion for {dept_info['department_name']}, we are requesting your assistance in gathering explanations for a complaint involving staff from your department.
|
We are requesting your assistance in gathering explanations for a complaint involving staff from {dept_info['department_name']}.
|
||||||
|
|
||||||
INVOLVED STAFF FROM YOUR DEPARTMENT:
|
INVOLVED STAFF FROM YOUR DEPARTMENT:
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
@ -659,6 +697,21 @@ This is an automated message from PX360 Complaint Management System."""
|
|||||||
email=champion_email,
|
email=champion_email,
|
||||||
subject=champion_subject,
|
subject=champion_subject,
|
||||||
message=champion_email_body,
|
message=champion_email_body,
|
||||||
|
html_message=f"""
|
||||||
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||||
|
{get_email_header_html()}
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Explanation Request - Complaint #{complaint.reference_number}</h2>
|
||||||
|
<p style="margin: 0 0 12px 0;">Dear {champion.get_full_name()},</p>
|
||||||
|
<p style="margin: 0 0 12px 0;">We are requesting your assistance in gathering explanations for a complaint involving staff from <strong>{dept_info['department_name']}</strong>.</p>
|
||||||
|
<p style="margin: 0 0 12px 0;">Please coordinate with the involved staff and submit the explanation.</p>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="{champion_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Explanation</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
related_object=complaint,
|
related_object=complaint,
|
||||||
metadata={
|
metadata={
|
||||||
"notification_type": "explanation_request",
|
"notification_type": "explanation_request",
|
||||||
@ -692,6 +745,51 @@ This is an automated message from PX360 Complaint Management System."""
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set SLA due date on each created explanation
|
||||||
|
now = timezone.now()
|
||||||
|
sla_config = _get_explanation_sla_config(complaint.hospital)
|
||||||
|
sla_hours = sla_config.response_hours if sla_config else 48
|
||||||
|
for result in results:
|
||||||
|
if result.get("sent") and result.get("explanation_id"):
|
||||||
|
ComplaintExplanation.objects.filter(id=result["explanation_id"]).update(
|
||||||
|
sla_due_at=now + timedelta(hours=sla_hours)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark complaint as sent to department and set forwarded timestamp
|
||||||
|
if champion_count > 0:
|
||||||
|
complaint.sent_to_department = True
|
||||||
|
complaint.sent_to_department_at = complaint.sent_to_department_at or now
|
||||||
|
complaint.forwarded_to_dept_at = complaint.forwarded_to_dept_at or now
|
||||||
|
complaint.explanation_requested = True
|
||||||
|
complaint.explanation_requested_at = complaint.explanation_requested_at or now
|
||||||
|
|
||||||
|
# Create/update ComplaintInvolvedDepartment records for each selected dept
|
||||||
|
from apps.complaints.models import ComplaintInvolvedDepartment as CID
|
||||||
|
for dept_id in selected_dept_ids:
|
||||||
|
dept_info = department_groups.get(dept_id)
|
||||||
|
if not dept_info:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dept = Department.objects.get(id=dept_id)
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
continue
|
||||||
|
inv_dept, created = CID.objects.get_or_create(
|
||||||
|
complaint=complaint,
|
||||||
|
department=dept,
|
||||||
|
defaults={
|
||||||
|
"role": "secondary",
|
||||||
|
"added_by": requested_by,
|
||||||
|
"sent": True,
|
||||||
|
"sent_at": now,
|
||||||
|
"forwarded_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not created and not inv_dept.sent:
|
||||||
|
inv_dept.sent = True
|
||||||
|
inv_dept.sent_at = inv_dept.sent_at or now
|
||||||
|
inv_dept.forwarded_at = inv_dept.forwarded_at or now
|
||||||
|
inv_dept.save()
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"champion_count": champion_count,
|
"champion_count": champion_count,
|
||||||
"skipped_no_email": skipped_no_email,
|
"skipped_no_email": skipped_no_email,
|
||||||
@ -727,13 +825,18 @@ This is an automated message from PX360 Complaint Management System."""
|
|||||||
"results": results,
|
"results": results,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
complaint.status = ComplaintStatus.CONTACTED
|
complaint.save(update_fields=[
|
||||||
complaint.save(update_fields=["status", "updated_at"])
|
"updated_at",
|
||||||
|
"sent_to_department", "sent_to_department_at",
|
||||||
|
"forwarded_to_dept_at",
|
||||||
|
"explanation_requested", "explanation_requested_at",
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"champion_count": champion_count,
|
"champion_count": champion_count,
|
||||||
"skipped_no_email": skipped_no_email,
|
"skipped_no_email": skipped_no_email,
|
||||||
"results": results,
|
"results": results,
|
||||||
|
"manager_count": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@ -130,55 +130,82 @@ def send_complaint_status_change_sms(sender, instance, created, **kwargs):
|
|||||||
if old_status == new_status:
|
if old_status == new_status:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Only send SMS if phone number is provided
|
# Only send if phone or email is provided
|
||||||
if not instance.contact_phone:
|
if not instance.contact_phone and not instance.contact_email:
|
||||||
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no phone number. Skipping SMS.")
|
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Send SMS notification
|
# Send SMS + email notification
|
||||||
try:
|
try:
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService, get_email_header_html
|
||||||
|
from apps.core.utils import build_public_track_url
|
||||||
|
|
||||||
# Bilingual SMS messages
|
track_url = build_public_track_url("complaint", instance.reference_number)
|
||||||
messages_en = {
|
|
||||||
'resolved': f"PX360: Your complaint #{instance.reference_number} has been resolved. Thank you for your feedback.",
|
|
||||||
'closed': f"PX360: Your complaint #{instance.reference_number} has been closed. Thank you for your feedback."
|
|
||||||
}
|
|
||||||
|
|
||||||
messages_ar = {
|
status_label = "resolved" if new_status == "resolved" else "closed"
|
||||||
'resolved': f"PX360: تم حل شكوتك #{instance.reference_number}. شكراً لتعاونكم.",
|
sms_message = f"PX360: Your complaint #{instance.reference_number} has been {status_label}. View response: {track_url}"
|
||||||
'closed': f"PX360: تم إغلاق شكوتك #{instance.reference_number}. شكراً لتعاونكم."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Default to English (can be enhanced to detect language)
|
if instance.contact_phone:
|
||||||
sms_message = messages_en.get(new_status, '')
|
notification_log = NotificationService.send_sms(
|
||||||
|
phone=instance.contact_phone,
|
||||||
|
message=sms_message,
|
||||||
|
related_object=instance,
|
||||||
|
metadata={
|
||||||
|
'notification_type': 'complaint_status_change',
|
||||||
|
'reference_number': instance.reference_number,
|
||||||
|
'old_status': old_status,
|
||||||
|
'new_status': new_status,
|
||||||
|
'language': 'en'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# Send SMS
|
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
|
||||||
notification_log = NotificationService.send_sms(
|
|
||||||
phone=instance.contact_phone,
|
|
||||||
message=sms_message,
|
|
||||||
related_object=instance,
|
|
||||||
metadata={
|
|
||||||
'notification_type': 'complaint_status_change',
|
|
||||||
'reference_number': instance.reference_number,
|
|
||||||
'old_status': old_status,
|
|
||||||
'new_status': new_status,
|
|
||||||
'language': 'en' # Default to English
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
|
if instance.contact_email:
|
||||||
|
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
|
||||||
|
email_body = (
|
||||||
|
f"Dear Valued Patient,\n\n"
|
||||||
|
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
|
||||||
|
f"To view the full response, please visit:\n{track_url}\n\n"
|
||||||
|
f"Thank you for your feedback.\n\n"
|
||||||
|
f"Reference: {instance.reference_number}\n"
|
||||||
|
f"This is an automated message from PX 360."
|
||||||
|
)
|
||||||
|
NotificationService.send_email(
|
||||||
|
email=instance.contact_email,
|
||||||
|
subject=email_subject,
|
||||||
|
message=email_body,
|
||||||
|
html_message=f"""
|
||||||
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
||||||
|
{get_email_header_html()}
|
||||||
|
<div style="padding: 20px;">
|
||||||
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Update: {status_label.title()}</h2>
|
||||||
|
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
|
||||||
|
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
|
||||||
|
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
|
||||||
|
<div style="text-align: center; margin: 20px 0;">
|
||||||
|
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
related_object=instance,
|
||||||
|
metadata={
|
||||||
|
'notification_type': 'complaint_status_change_email',
|
||||||
|
'reference_number': instance.reference_number,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
|
||||||
|
|
||||||
# Create complaint update to track SMS
|
|
||||||
ComplaintUpdate.objects.create(
|
ComplaintUpdate.objects.create(
|
||||||
complaint=instance,
|
complaint=instance,
|
||||||
update_type='communication',
|
update_type='communication',
|
||||||
message=f"SMS notification sent to complainant: Status changed to {new_status}",
|
message=f"Notification sent to complainant: Status changed to {new_status} (SMS: {bool(instance.contact_phone)}, Email: {bool(instance.contact_email)})",
|
||||||
metadata={
|
metadata={
|
||||||
'notification_type': 'complaint_status_change',
|
'notification_type': 'complaint_status_change',
|
||||||
'old_status': old_status,
|
'old_status': old_status,
|
||||||
'new_status': new_status,
|
'new_status': new_status,
|
||||||
'notification_log_id': str(notification_log.id) if notification_log else None
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -216,8 +243,12 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
|
|||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Only notify when this department is actually being sent to (not just added to involvement list)
|
||||||
|
if not instance.sent:
|
||||||
|
return
|
||||||
|
|
||||||
# Only notify if the department has a respondent (champion) with email
|
# Only notify if the department has a respondent (champion) with email
|
||||||
if not instance.department.respondent or not instance.department.respondent.user or not instance.department.respondent.user.email:
|
if not instance.department.champion or not instance.department.champion.user or not instance.department.champion.user.email:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
|
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
|
||||||
f"'{instance.department.name}'. Skipping notification."
|
f"'{instance.department.name}'. Skipping notification."
|
||||||
@ -225,10 +256,10 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService, get_email_header_html
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
|
||||||
champion = instance.department.respondent.user
|
champion = instance.department.champion.user
|
||||||
complaint = instance.complaint
|
complaint = instance.complaint
|
||||||
department = instance.department
|
department = instance.department
|
||||||
|
|
||||||
@ -253,17 +284,20 @@ Please review and respond through your department page:
|
|||||||
Best regards,
|
Best regards,
|
||||||
PX360 Team""",
|
PX360 Team""",
|
||||||
html_message=f"""
|
html_message=f"""
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
<h2 style="color: #005696;">New Complaint Assigned</h2>
|
{get_email_header_html()}
|
||||||
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
|
<div style="padding: 30px;">
|
||||||
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
|
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
|
||||||
<p><strong>Reference:</strong> {complaint.reference_number}</p>
|
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
|
||||||
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
|
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
|
||||||
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
|
<p><strong>Reference:</strong> {complaint.reference_number}</p>
|
||||||
|
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
|
||||||
|
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<p>Please review and respond through your department page:</p>
|
||||||
|
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Please review and respond through your department page:</p>
|
|
||||||
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
|
|
||||||
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
|
|
||||||
</div>
|
</div>
|
||||||
""",
|
""",
|
||||||
related_object=complaint,
|
related_object=complaint,
|
||||||
|
|||||||
@ -671,6 +671,16 @@ def create_action_from_complaint(complaint_id):
|
|||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def escalate_complaint_auto(complaint_id):
|
def escalate_complaint_auto(complaint_id):
|
||||||
|
"""
|
||||||
|
Disabled: auto-escalation is turned off. Manual escalation only.
|
||||||
|
Kept for backward compatibility — returns immediately.
|
||||||
|
"""
|
||||||
|
logger.info(f"Auto-escalation is disabled. Skipping for complaint {complaint_id}.")
|
||||||
|
return {"status": "auto_escalation_disabled", "complaint_id": complaint_id}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def _escalate_complaint_auto_original(complaint_id):
|
||||||
"""
|
"""
|
||||||
Automatically escalate complaint based on escalation rules.
|
Automatically escalate complaint based on escalation rules.
|
||||||
|
|
||||||
@ -931,21 +941,9 @@ def escalate_after_reminder(complaint_id):
|
|||||||
"hours_since_reminder": (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600,
|
"hours_since_reminder": (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trigger the regular escalation task
|
# Auto-escalation disabled — manual escalation only
|
||||||
result = escalate_complaint_auto.delay(complaint_id)
|
|
||||||
|
|
||||||
# Add metadata about this being a reminder-based escalation
|
|
||||||
if complaint.metadata:
|
|
||||||
complaint.metadata["reminder_escalation"] = {
|
|
||||||
"rule_id": str(matching_rule.id),
|
|
||||||
"rule_name": matching_rule.name,
|
|
||||||
"hours_since_reminder": (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600,
|
|
||||||
"timestamp": timezone.now().isoformat(),
|
|
||||||
}
|
|
||||||
complaint.save(update_fields=["metadata"])
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Reminder-based escalation triggered for complaint {complaint_id} using rule '{matching_rule.name}'"
|
f"Reminder-based auto-escalation skipped for complaint {complaint_id} (auto-escalation disabled)"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "reminder_escalation_triggered", "rule": matching_rule.name, "escalation_result": result}
|
return {"status": "reminder_escalation_triggered", "rule": matching_rule.name, "escalation_result": result}
|
||||||
@ -1307,6 +1305,8 @@ def analyze_complaint_with_ai(complaint_id):
|
|||||||
"department",
|
"department",
|
||||||
"staff",
|
"staff",
|
||||||
"title",
|
"title",
|
||||||
|
"ai_brief_en",
|
||||||
|
"ai_brief_ar",
|
||||||
"metadata",
|
"metadata",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -1655,6 +1655,8 @@ def _apply_complaint_ai_analysis(complaint, analysis, emotion_analysis):
|
|||||||
"department",
|
"department",
|
||||||
"staff",
|
"staff",
|
||||||
"title",
|
"title",
|
||||||
|
"ai_brief_en",
|
||||||
|
"ai_brief_ar",
|
||||||
"metadata",
|
"metadata",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -1788,6 +1790,33 @@ def get_explanation_sla_config(hospital):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_max_escalation_reached(explanation):
|
||||||
|
"""Notify hospital admins and PX staff when explanation escalation has reached max level."""
|
||||||
|
from apps.complaints.services.complaint_service import ComplaintService
|
||||||
|
from apps.notifications.services import NotificationService
|
||||||
|
|
||||||
|
complaint = explanation.complaint
|
||||||
|
hospital = complaint.hospital
|
||||||
|
target_user, _ = ComplaintService.get_escalation_target(complaint, staff=explanation.staff)
|
||||||
|
|
||||||
|
if target_user and target_user.email:
|
||||||
|
try:
|
||||||
|
NotificationService.send_email(
|
||||||
|
target_user.email,
|
||||||
|
subject=f"Explanation Escalation Limit Reached - {complaint.reference_number}",
|
||||||
|
message=(
|
||||||
|
f"All escalation levels have been exhausted for explanation request "
|
||||||
|
f"on complaint {complaint.reference_number}.\n\n"
|
||||||
|
f"Staff: {explanation.staff.get_full_name() if explanation.staff else 'Unknown'}\n"
|
||||||
|
f"Hospital: {hospital.name}\n\n"
|
||||||
|
f"Please take immediate action."
|
||||||
|
),
|
||||||
|
related_object=complaint,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send max escalation notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_explanation_request_email(explanation_id):
|
def send_explanation_request_email(explanation_id):
|
||||||
"""
|
"""
|
||||||
@ -1813,15 +1842,30 @@ def send_explanation_request_email(explanation_id):
|
|||||||
explanation.email_sent_at = timezone.now()
|
explanation.email_sent_at = timezone.now()
|
||||||
explanation.save(update_fields=["sla_due_at", "email_sent_at"])
|
explanation.save(update_fields=["sla_due_at", "email_sent_at"])
|
||||||
|
|
||||||
|
complaint = explanation.complaint
|
||||||
|
staff = explanation.staff
|
||||||
|
requested_by = explanation.requested_by
|
||||||
|
site_url = settings.SITE_URL if hasattr(settings, "SITE_URL") else "http://localhost:8000"
|
||||||
|
explanation_url = f"{site_url}/complaints/explanation/{explanation.token}/"
|
||||||
|
|
||||||
# Prepare email
|
# Prepare email
|
||||||
context = {
|
context = {
|
||||||
"explanation": explanation,
|
"explanation": explanation,
|
||||||
"complaint": explanation.complaint,
|
"complaint": complaint,
|
||||||
"staff": explanation.staff,
|
"staff": staff,
|
||||||
"requested_by": explanation.requested_by,
|
"requested_by": requested_by,
|
||||||
"sla_hours": sla_hours,
|
"sla_hours": sla_hours,
|
||||||
"due_date": explanation.sla_due_at,
|
"due_date": explanation.sla_due_at,
|
||||||
"site_url": settings.SITE_URL if hasattr(settings, "SITE_URL") else "http://localhost:8000",
|
"site_url": site_url,
|
||||||
|
"staff_name": staff.get_full_name() if staff else "Team",
|
||||||
|
"complaint_id": str(complaint.id)[:8],
|
||||||
|
"complaint_title": complaint.title,
|
||||||
|
"patient_name": complaint.patient_name or "N/A",
|
||||||
|
"department_name": complaint.department.name if complaint.department else "N/A",
|
||||||
|
"created_date": explanation.sla_due_at,
|
||||||
|
"description": complaint.description,
|
||||||
|
"custom_message": explanation.request_message,
|
||||||
|
"explanation_url": explanation_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = f"Explanation Request: Complaint #{str(explanation.complaint.id)[:8]}"
|
subject = f"Explanation Request: Complaint #{str(explanation.complaint.id)[:8]}"
|
||||||
@ -1830,28 +1874,7 @@ def send_explanation_request_email(explanation_id):
|
|||||||
html_message = render_to_string("emails/explanation_request.html", context)
|
html_message = render_to_string("emails/explanation_request.html", context)
|
||||||
|
|
||||||
# Plain text fallback
|
# Plain text fallback
|
||||||
message_text = (
|
message_text = render_to_string("complaints/emails/explanation_request_en.txt", context)
|
||||||
render_to_string("complaints/emails/explanation_request_en.txt", context)
|
|
||||||
if context.get("complaint", {}).get("description")
|
|
||||||
else f"""
|
|
||||||
Explanation Request - Complaint #{str(explanation.complaint.id)[:8]}
|
|
||||||
|
|
||||||
Dear {explanation.staff.get_full_name()},
|
|
||||||
|
|
||||||
You have been assigned to provide an explanation for a patient complaint.
|
|
||||||
|
|
||||||
Complaint Reference: #{str(explanation.complaint.id)[:8]}
|
|
||||||
Patient: {explanation.complaint.patient_name if hasattr(explanation.complaint, "patient_name") else "N/A"}
|
|
||||||
Hospital: {explanation.complaint.hospital.name}
|
|
||||||
Department: {explanation.complaint.department.name if explanation.complaint.department else "N/A"}
|
|
||||||
|
|
||||||
Please submit your explanation using the link provided in the HTML email.
|
|
||||||
|
|
||||||
Thank you,
|
|
||||||
PX360 Complaint Management System
|
|
||||||
Al Hammadi Hospital
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send email
|
# Send email
|
||||||
send_mail(
|
send_mail(
|
||||||
@ -1936,6 +1959,7 @@ def check_overdue_explanation_requests():
|
|||||||
|
|
||||||
if current_level >= max_level:
|
if current_level >= max_level:
|
||||||
logger.info(f"Explanation {explanation.id} reached max escalation level {max_level}")
|
logger.info(f"Explanation {explanation.id} reached max escalation level {max_level}")
|
||||||
|
_notify_max_escalation_reached(explanation)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate hours overdue
|
# Calculate hours overdue
|
||||||
@ -3007,7 +3031,7 @@ def notify_staff_new_item(item_type, item_id):
|
|||||||
"department_field": "department",
|
"department_field": "department",
|
||||||
"has_timeline": True,
|
"has_timeline": True,
|
||||||
"timeline_model": "apps.complaints.models.ComplaintUpdate",
|
"timeline_model": "apps.complaints.models.ComplaintUpdate",
|
||||||
"timeline_parent_field": "complaint",
|
"timeline_parent_field": "__self__",
|
||||||
},
|
},
|
||||||
"inquiry": {
|
"inquiry": {
|
||||||
"model_path": "apps.complaints.models.Inquiry",
|
"model_path": "apps.complaints.models.Inquiry",
|
||||||
@ -3325,7 +3349,10 @@ This is an automated notification from the PX 360 system.
|
|||||||
timeline_module = __import__(timeline_module_path, fromlist=[timeline_model_name])
|
timeline_module = __import__(timeline_module_path, fromlist=[timeline_model_name])
|
||||||
TimelineClass = getattr(timeline_module, timeline_model_name)
|
TimelineClass = getattr(timeline_module, timeline_model_name)
|
||||||
|
|
||||||
timeline_parent = getattr(item, config["timeline_parent_field"])
|
if config["timeline_parent_field"] == "__self__":
|
||||||
|
timeline_parent = item
|
||||||
|
else:
|
||||||
|
timeline_parent = getattr(item, config["timeline_parent_field"])
|
||||||
TimelineClass.objects.create(
|
TimelineClass.objects.create(
|
||||||
complaint=timeline_parent,
|
complaint=timeline_parent,
|
||||||
update_type="note",
|
update_type="note",
|
||||||
@ -3660,32 +3687,9 @@ def check_overdue_inquiry_dept_responses():
|
|||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if dept.manager and dept.manager.email:
|
# Auto-escalation disabled — manual escalation only
|
||||||
try:
|
logger.info(f"Auto-escalation skipped for inquiry {inquiry.reference_number} (disabled)")
|
||||||
NotificationService.send_email(
|
continue
|
||||||
email=dept.manager.email,
|
|
||||||
subject=f"ESCALATION: Inquiry #{inquiry.reference_number} - Department Response Overdue",
|
|
||||||
message=(
|
|
||||||
f"The department response for inquiry #{inquiry.reference_number} "
|
|
||||||
f"({inquiry.subject}) is overdue. The response deadline was "
|
|
||||||
f"{inquiry.dept_response_sla_due_at.strftime('%Y-%m-%d %H:%M')}. "
|
|
||||||
f"Please ensure the department submits a response immediately."
|
|
||||||
),
|
|
||||||
related_object=inquiry,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send escalation email: {e}")
|
|
||||||
|
|
||||||
inquiry.dept_response_escalated_at = now
|
|
||||||
inquiry.save(update_fields=["dept_response_escalated_at"])
|
|
||||||
escalated_count += 1
|
|
||||||
|
|
||||||
InquiryUpdate.objects.create(
|
|
||||||
inquiry=inquiry,
|
|
||||||
update_type="note",
|
|
||||||
message=f"Department response SLA escalated to {dept.get_localized_name()} manager",
|
|
||||||
created_by=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if overdue_count > 0 or escalated_count > 0:
|
if overdue_count > 0 or escalated_count > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -3726,8 +3730,8 @@ def send_inquiry_dept_response_reminders():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
recipients = []
|
recipients = []
|
||||||
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
|
if dept.champion and dept.champion.user and dept.champion.user.email:
|
||||||
recipients.append(dept.respondent.user)
|
recipients.append(dept.champion.user)
|
||||||
|
|
||||||
if not recipients:
|
if not recipients:
|
||||||
continue
|
continue
|
||||||
|
|||||||
196
apps/complaints/tests.py
Normal file
196
apps/complaints/tests.py
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
Tests for public complaint form and view.
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.complaints.models import Complaint, ComplaintSourceType, Inquiry
|
||||||
|
from apps.organizations.models import Area, Department, Hospital, LocationType, Section
|
||||||
|
|
||||||
|
|
||||||
|
class PublicComplaintViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.hospital = Hospital.objects.create(
|
||||||
|
name="Test Hospital",
|
||||||
|
code="TEST",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.department = Department.objects.create(
|
||||||
|
hospital=self.hospital,
|
||||||
|
name="Emergency",
|
||||||
|
name_en="Emergency",
|
||||||
|
code="test_er",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
department=self.department,
|
||||||
|
name_en="ER Section A",
|
||||||
|
code="test_er_a",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.area = Area.objects.create(
|
||||||
|
hospital=self.hospital,
|
||||||
|
name_en="Main Lobby",
|
||||||
|
code="lobby",
|
||||||
|
location_type=LocationType.OP,
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_complaint_form_get(self):
|
||||||
|
try:
|
||||||
|
response = self.client.get(reverse("complaints:public_complaint_submit"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_public_complaint_post_saves_location_type(self):
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"relation_to_patient": "patient",
|
||||||
|
"patient_name": "Jane Doe",
|
||||||
|
"national_id": "1234567890",
|
||||||
|
"incident_date": date.today().isoformat(),
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "OP",
|
||||||
|
"category": "medical",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"section": str(self.section.id),
|
||||||
|
"complaint_details": "Test complaint with enough detail.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
result = response.json()
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
complaint = Complaint.objects.first()
|
||||||
|
self.assertIsNotNone(complaint)
|
||||||
|
self.assertEqual(complaint.location_type, "OP")
|
||||||
|
self.assertEqual(complaint.department_id, self.department.id)
|
||||||
|
self.assertEqual(complaint.section_id, self.section.id)
|
||||||
|
self.assertEqual(complaint.hospital_id, self.hospital.id)
|
||||||
|
|
||||||
|
def test_public_complaint_post_missing_location_type_succeeds(self):
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"relation_to_patient": "patient",
|
||||||
|
"patient_name": "Jane Doe",
|
||||||
|
"national_id": "1234567890",
|
||||||
|
"incident_date": date.today().isoformat(),
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "",
|
||||||
|
"category": "medical",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"complaint_details": "Test complaint.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
result = response.json()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
|
||||||
|
def test_public_complaint_post_with_all_location_types(self):
|
||||||
|
for loc_type in ["OP", "IP", "ER", "GENERAL"]:
|
||||||
|
Complaint.objects.all().delete()
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"relation_to_patient": "patient",
|
||||||
|
"patient_name": "Jane Doe",
|
||||||
|
"national_id": "1234567890",
|
||||||
|
"incident_date": date.today().isoformat(),
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": loc_type,
|
||||||
|
"category": "medical",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"complaint_details": f"Test for {loc_type}",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, f"Failed for location_type={loc_type}")
|
||||||
|
complaint = Complaint.objects.first()
|
||||||
|
self.assertEqual(complaint.location_type, loc_type)
|
||||||
|
|
||||||
|
def test_public_complaint_post_invalid_hospital(self):
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"hospital": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"location_type": "OP",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"complaint_details": "Test.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_public_complaint_post_section_optional(self):
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"relation_to_patient": "patient",
|
||||||
|
"patient_name": "Jane Doe",
|
||||||
|
"national_id": "1234567890",
|
||||||
|
"incident_date": date.today().isoformat(),
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "IP",
|
||||||
|
"category": "medical",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"complaint_details": "Test without section.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
complaint = Complaint.objects.first()
|
||||||
|
self.assertIsNone(complaint.section_id)
|
||||||
|
self.assertEqual(complaint.location_type, "IP")
|
||||||
|
|
||||||
|
def test_public_complaint_post_saves_contact_info(self):
|
||||||
|
data = {
|
||||||
|
"complainant_name": "John Doe",
|
||||||
|
"mobile_number": "0512345678",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"relation_to_patient": "relative",
|
||||||
|
"patient_name": "Jane Doe",
|
||||||
|
"national_id": "1234567890",
|
||||||
|
"incident_date": date.today().isoformat(),
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "ER",
|
||||||
|
"category": "medical",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"complaint_details": "Contact info test.",
|
||||||
|
"staff_name": "Dr. Smith",
|
||||||
|
"expected_result": "Quick resolution",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("complaints:public_complaint_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
complaint = Complaint.objects.first()
|
||||||
|
self.assertEqual(complaint.contact_name, "John Doe")
|
||||||
|
self.assertEqual(complaint.contact_phone, "0512345678")
|
||||||
|
self.assertEqual(complaint.contact_email, "john@example.com")
|
||||||
|
self.assertEqual(complaint.staff_name, "Dr. Smith")
|
||||||
|
self.assertEqual(complaint.expected_result, "Quick resolution")
|
||||||
233
apps/complaints/tests_inquiry.py
Normal file
233
apps/complaints/tests_inquiry.py
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
Tests for public inquiry form and view (both complaints and core app handlers).
|
||||||
|
"""
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.complaints.models import Inquiry
|
||||||
|
from apps.organizations.models import Area, Department, Hospital, LocationType, Section
|
||||||
|
|
||||||
|
|
||||||
|
class PublicInquiryViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.hospital = Hospital.objects.create(
|
||||||
|
name="Test Hospital",
|
||||||
|
code="TEST",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.department = Department.objects.create(
|
||||||
|
hospital=self.hospital,
|
||||||
|
name="Reception",
|
||||||
|
name_en="Reception",
|
||||||
|
code="test_recv",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
department=self.department,
|
||||||
|
name_en="Front Desk",
|
||||||
|
code="test_recv_fd",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_inquiry_form_get(self):
|
||||||
|
response = self.client.get(reverse("inquiries:public_inquiry_submit"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_public_inquiry_post_saves_location_type(self):
|
||||||
|
data = {
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"phone": "0598765432",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "OP",
|
||||||
|
"category": "general",
|
||||||
|
"subject": "Test Inquiry",
|
||||||
|
"message": "This is a test inquiry message.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inquiries:public_inquiry_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
result = response.json()
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
inquiry = Inquiry.objects.first()
|
||||||
|
self.assertIsNotNone(inquiry)
|
||||||
|
self.assertEqual(inquiry.location_type, "OP")
|
||||||
|
self.assertEqual(inquiry.hospital_id, self.hospital.id)
|
||||||
|
|
||||||
|
def test_public_inquiry_post_with_dept_and_section(self):
|
||||||
|
data = {
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"phone": "0598765432",
|
||||||
|
"email": "",
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "IP",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"section": str(self.section.id),
|
||||||
|
"category": "billing",
|
||||||
|
"subject": "Billing Question",
|
||||||
|
"message": "I have a question about my bill.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inquiries:public_inquiry_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
inquiry = Inquiry.objects.first()
|
||||||
|
self.assertEqual(inquiry.location_type, "IP")
|
||||||
|
self.assertEqual(inquiry.department_id, self.department.id)
|
||||||
|
self.assertEqual(inquiry.section_id, self.section.id)
|
||||||
|
|
||||||
|
def test_public_inquiry_post_missing_required_fields(self):
|
||||||
|
data = {
|
||||||
|
"name": "",
|
||||||
|
"phone": "",
|
||||||
|
"message": "",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inquiries:public_inquiry_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
result = response.json()
|
||||||
|
self.assertFalse(result["success"])
|
||||||
|
|
||||||
|
def test_public_inquiry_post_invalid_hospital(self):
|
||||||
|
data = {
|
||||||
|
"name": "Jane",
|
||||||
|
"phone": "0598765432",
|
||||||
|
"hospital": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"subject": "Test",
|
||||||
|
"message": "Test message.",
|
||||||
|
}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("inquiries:public_inquiry_submit"),
|
||||||
|
data,
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreInquiryViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.hospital = Hospital.objects.create(
|
||||||
|
name="Core Test Hospital",
|
||||||
|
code="CORE",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.department = Department.objects.create(
|
||||||
|
hospital=self.hospital,
|
||||||
|
name="Admin",
|
||||||
|
name_en="Admin",
|
||||||
|
code="core_admin",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
department=self.department,
|
||||||
|
name_en="HR Section",
|
||||||
|
code="core_admin_hr",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_inquiry_post_saves_location_type(self):
|
||||||
|
data = {
|
||||||
|
"name": "Alice Smith",
|
||||||
|
"phone": "0511122233",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "ER",
|
||||||
|
"category": "appointment",
|
||||||
|
"subject": "Appointment Inquiry",
|
||||||
|
"message": "When is my appointment?",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"section": str(self.section.id),
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("core:public_inquiry_submit"), data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
result = response.json()
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
inquiry = Inquiry.objects.first()
|
||||||
|
self.assertIsNotNone(inquiry)
|
||||||
|
self.assertEqual(inquiry.location_type, "ER")
|
||||||
|
self.assertEqual(inquiry.department_id, self.department.id)
|
||||||
|
self.assertEqual(inquiry.section_id, self.section.id)
|
||||||
|
|
||||||
|
def test_core_inquiry_post_without_location_type(self):
|
||||||
|
data = {
|
||||||
|
"name": "Bob Jones",
|
||||||
|
"phone": "0544455566",
|
||||||
|
"email": "",
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "",
|
||||||
|
"subject": "General Question",
|
||||||
|
"message": "Just wondering about something.",
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("core:public_inquiry_submit"), data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
inquiry = Inquiry.objects.first()
|
||||||
|
self.assertEqual(inquiry.location_type, "")
|
||||||
|
|
||||||
|
def test_core_inquiry_post_missing_name(self):
|
||||||
|
data = {
|
||||||
|
"name": "",
|
||||||
|
"phone": "0544455566",
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"message": "Test.",
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("core:public_inquiry_submit"), data)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class CoreObservationViewTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.hospital = Hospital.objects.create(
|
||||||
|
name="Obs Test Hospital",
|
||||||
|
code="OBS",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.department = Department.objects.create(
|
||||||
|
hospital=self.hospital,
|
||||||
|
name="Lab",
|
||||||
|
name_en="Lab",
|
||||||
|
code="obs_lab",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.section = Section.objects.create(
|
||||||
|
department=self.department,
|
||||||
|
name_en="Blood Draw",
|
||||||
|
code="obs_lab_bd",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_observation_post_saves_location_type(self):
|
||||||
|
data = {
|
||||||
|
"hospital": str(self.hospital.id),
|
||||||
|
"location_type": "OP",
|
||||||
|
"description": "I noticed something wrong in the lab area.",
|
||||||
|
"severity": "medium",
|
||||||
|
"department": str(self.department.id),
|
||||||
|
"section": str(self.section.id),
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("core:public_observation_submit"), data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
result = response.json()
|
||||||
|
self.assertTrue(result["success"])
|
||||||
|
|
||||||
|
from apps.observations.models import Observation
|
||||||
|
|
||||||
|
obs = Observation.objects.first()
|
||||||
|
self.assertIsNotNone(obs)
|
||||||
|
self.assertEqual(obs.location_type, "OP")
|
||||||
|
self.assertEqual(obs.assigned_department_id, self.department.id)
|
||||||
|
self.assertEqual(obs.section_id, self.section.id)
|
||||||
File diff suppressed because it is too large
Load Diff
@ -20,7 +20,7 @@ def send_to_department_form(request, pk):
|
|||||||
Complaint.objects.prefetch_related(
|
Complaint.objects.prefetch_related(
|
||||||
"involved_staff__staff__department",
|
"involved_staff__staff__department",
|
||||||
"involved_staff__staff__report_to",
|
"involved_staff__staff__report_to",
|
||||||
"involved_departments__department__respondent",
|
"involved_departments__department__champion",
|
||||||
"involved_departments__department__manager",
|
"involved_departments__department__manager",
|
||||||
),
|
),
|
||||||
pk=pk,
|
pk=pk,
|
||||||
@ -55,25 +55,25 @@ def send_to_department_form(request, pk):
|
|||||||
|
|
||||||
# Build department groups from involved_departments first
|
# Build department groups from involved_departments first
|
||||||
involved_departments = complaint.involved_departments.select_related(
|
involved_departments = complaint.involved_departments.select_related(
|
||||||
"department__respondent", "department__manager"
|
"department__champion", "department__manager"
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
for dept_inv in involved_departments:
|
for dept_inv in involved_departments:
|
||||||
dept = dept_inv.department
|
dept = dept_inv.department
|
||||||
if not dept or not dept.respondent:
|
if not dept:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dept_key = str(dept.id)
|
dept_key = str(dept.id)
|
||||||
if dept_key not in department_groups:
|
if dept_key not in department_groups:
|
||||||
champion = dept.respondent
|
champion = dept.champion
|
||||||
champion_email = champion.email or (champion.user.email if champion.user else None)
|
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
|
||||||
dept_manager = dept.manager
|
dept_manager = dept.manager
|
||||||
department_groups[dept_key] = {
|
department_groups[dept_key] = {
|
||||||
"department_id": dept_key,
|
"department_id": dept_key,
|
||||||
"department_name": dept.get_localized_name(),
|
"department_name": dept.get_localized_name(),
|
||||||
"champion": champion,
|
"champion": champion,
|
||||||
"champion_id": str(champion.id),
|
"champion_id": str(champion.id) if champion else None,
|
||||||
"champion_name": champion.get_full_name(),
|
"champion_name": champion.get_full_name() if champion else None,
|
||||||
"champion_email": champion_email,
|
"champion_email": champion_email,
|
||||||
"dept_manager": dept_manager,
|
"dept_manager": dept_manager,
|
||||||
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
|
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
|
||||||
@ -95,22 +95,20 @@ def send_to_department_form(request, pk):
|
|||||||
"role": staff_inv.get_role_display(),
|
"role": staff_inv.get_role_display(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if dept and dept.respondent:
|
if dept:
|
||||||
dept_key = str(dept.id)
|
dept_key = str(dept.id)
|
||||||
if dept_key in department_groups:
|
if dept_key in department_groups:
|
||||||
department_groups[dept_key]["staff_list"].append(entry)
|
department_groups[dept_key]["staff_list"].append(entry)
|
||||||
else:
|
else:
|
||||||
# This shouldn't happen if we built department_groups from involved_departments,
|
champion = dept.champion
|
||||||
# but handle as fallback
|
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
|
||||||
champion = dept.respondent
|
|
||||||
champion_email = champion.email or (champion.user.email if champion.user else None)
|
|
||||||
dept_manager = dept.manager
|
dept_manager = dept.manager
|
||||||
department_groups[dept_key] = {
|
department_groups[dept_key] = {
|
||||||
"department_id": dept_key,
|
"department_id": dept_key,
|
||||||
"department_name": dept.get_localized_name(),
|
"department_name": dept.get_localized_name(),
|
||||||
"champion": champion,
|
"champion": champion,
|
||||||
"champion_id": str(champion.id),
|
"champion_id": str(champion.id) if champion else None,
|
||||||
"champion_name": champion.get_full_name(),
|
"champion_name": champion.get_full_name() if champion else None,
|
||||||
"champion_email": champion_email,
|
"champion_email": champion_email,
|
||||||
"dept_manager": dept_manager,
|
"dept_manager": dept_manager,
|
||||||
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
|
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
|
||||||
@ -121,10 +119,10 @@ def send_to_department_form(request, pk):
|
|||||||
else:
|
else:
|
||||||
ungrouped_staff.append(entry)
|
ungrouped_staff.append(entry)
|
||||||
|
|
||||||
# Only show error if there are NO departments with champions AND no staff
|
|
||||||
if not department_groups and not involved_staff.exists():
|
if not department_groups and not involved_staff.exists():
|
||||||
messages.error(request, _("No staff members or departments are involved in this complaint."))
|
if not involved_departments.exists():
|
||||||
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
messages.error(request, _("No departments are involved in this complaint. Please add a department first."))
|
||||||
|
return redirect("complaints:complaint_detail", pk=complaint.pk)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
action = request.POST.get("action", "send")
|
action = request.POST.get("action", "send")
|
||||||
@ -148,8 +146,26 @@ def send_to_department_form(request, pk):
|
|||||||
for dept_id in selected_dept_ids:
|
for dept_id in selected_dept_ids:
|
||||||
dept_info = department_groups.get(dept_id)
|
dept_info = department_groups.get(dept_id)
|
||||||
if dept_info:
|
if dept_info:
|
||||||
|
contact_person_id = request.POST.get(f"contact_person_{dept_id}", "")
|
||||||
|
if contact_person_id:
|
||||||
|
from apps.organizations.models import Department as DeptModel
|
||||||
|
dept_obj = DeptModel.objects.filter(pk=dept_id).first()
|
||||||
|
if dept_obj:
|
||||||
|
cinfo = dept_obj.is_valid_contact_person(contact_person_id)
|
||||||
|
if cinfo:
|
||||||
|
dept_info["contact_person_id"] = contact_person_id
|
||||||
|
dept_info["contact_person_name"] = cinfo["name"]
|
||||||
|
dept_info["contact_person_role"] = cinfo["role_label"]
|
||||||
|
dept_info["contact_person_email"] = cinfo["email"]
|
||||||
|
dept_info["contact_person_staff"] = cinfo["staff"]
|
||||||
preview_depts.append(dept_info)
|
preview_depts.append(dept_info)
|
||||||
|
|
||||||
|
contact_person_map = {
|
||||||
|
d["department_id"]: d.get("contact_person_id", "")
|
||||||
|
for d in preview_depts
|
||||||
|
if d.get("contact_person_id")
|
||||||
|
}
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"complaints/send_to_department_preview.html",
|
"complaints/send_to_department_preview.html",
|
||||||
@ -158,11 +174,18 @@ def send_to_department_form(request, pk):
|
|||||||
"preview_depts": preview_depts,
|
"preview_depts": preview_depts,
|
||||||
"selected_dept_ids": selected_dept_ids,
|
"selected_dept_ids": selected_dept_ids,
|
||||||
"request_message": request_message,
|
"request_message": request_message,
|
||||||
|
"contact_person_map": contact_person_map,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
|
||||||
|
contact_person_map = {}
|
||||||
|
for dept_id in selected_dept_ids:
|
||||||
|
cp_id = request.POST.get(f"contact_person_{dept_id}", "")
|
||||||
|
if cp_id:
|
||||||
|
contact_person_map[dept_id] = cp_id
|
||||||
|
|
||||||
site = get_current_site(request)
|
site = get_current_site(request)
|
||||||
results = ComplaintService.send_to_department(
|
results = ComplaintService.send_to_department(
|
||||||
complaint,
|
complaint,
|
||||||
@ -172,8 +195,13 @@ def send_to_department_form(request, pk):
|
|||||||
request.user,
|
request.user,
|
||||||
site.domain,
|
site.domain,
|
||||||
request=request,
|
request=request,
|
||||||
|
contact_person_map=contact_person_map,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not complaint.forwarded_to_dept_at:
|
||||||
|
complaint.forwarded_to_dept_at = timezone.now()
|
||||||
|
complaint.save(update_fields=["forwarded_to_dept_at"])
|
||||||
|
|
||||||
if results["champion_count"] == 0 and results["manager_count"] == 0:
|
if results["champion_count"] == 0 and results["manager_count"] == 0:
|
||||||
if results["skipped_no_email"] > 0:
|
if results["skipped_no_email"] > 0:
|
||||||
messages.warning(
|
messages.warning(
|
||||||
|
|||||||
@ -8,6 +8,9 @@ from .views import (
|
|||||||
ComplaintViewSet,
|
ComplaintViewSet,
|
||||||
InquiryViewSet,
|
InquiryViewSet,
|
||||||
complaint_explanation_form,
|
complaint_explanation_form,
|
||||||
|
champion_start_investigation,
|
||||||
|
staff_investigation_form,
|
||||||
|
champion_review_answers,
|
||||||
generate_complaint_pdf,
|
generate_complaint_pdf,
|
||||||
api_locations,
|
api_locations,
|
||||||
api_sections,
|
api_sections,
|
||||||
@ -33,6 +36,7 @@ urlpatterns = [
|
|||||||
path("<uuid:pk>/assign/", ui_views.complaint_assign, name="complaint_assign"),
|
path("<uuid:pk>/assign/", ui_views.complaint_assign, name="complaint_assign"),
|
||||||
path("<uuid:pk>/change-status/", ui_views.complaint_change_status, name="complaint_change_status"),
|
path("<uuid:pk>/change-status/", ui_views.complaint_change_status, name="complaint_change_status"),
|
||||||
path("<uuid:pk>/update-satisfaction/", ui_views.update_satisfaction, name="update_satisfaction"),
|
path("<uuid:pk>/update-satisfaction/", ui_views.update_satisfaction, name="update_satisfaction"),
|
||||||
|
path("<uuid:pk>/update-patient-contact-status/", ui_views.update_patient_contact_status, name="update_patient_contact_status"),
|
||||||
path("<uuid:pk>/toggle-escalated-ovr/", ui_views.toggle_escalated_ovr, name="toggle_escalated_ovr"),
|
path("<uuid:pk>/toggle-escalated-ovr/", ui_views.toggle_escalated_ovr, name="toggle_escalated_ovr"),
|
||||||
path("<uuid:pk>/approve-ovr/", ui_views.approve_ovr_escalation, name="approve_ovr_escalation"),
|
path("<uuid:pk>/approve-ovr/", ui_views.approve_ovr_escalation, name="approve_ovr_escalation"),
|
||||||
path("<uuid:pk>/reject-ovr/", ui_views.reject_ovr_escalation, name="reject_ovr_escalation"),
|
path("<uuid:pk>/reject-ovr/", ui_views.reject_ovr_escalation, name="reject_ovr_escalation"),
|
||||||
@ -131,6 +135,9 @@ urlpatterns = [
|
|||||||
path("public/api/hospitals/<int:hospital_id>/departments/", api_departments, name="api_departments"),
|
path("public/api/hospitals/<int:hospital_id>/departments/", api_departments, name="api_departments"),
|
||||||
# Public Explanation Form (No Authentication Required)
|
# Public Explanation Form (No Authentication Required)
|
||||||
path("<uuid:complaint_id>/explain/<str:token>/", complaint_explanation_form, name="complaint_explanation_form"),
|
path("<uuid:complaint_id>/explain/<str:token>/", complaint_explanation_form, name="complaint_explanation_form"),
|
||||||
|
path("<uuid:complaint_id>/investigate/<str:token>/", champion_start_investigation, name="champion_start_investigation"),
|
||||||
|
path("<uuid:complaint_id>/investigate/respond/<str:token>/", staff_investigation_form, name="staff_investigation_form"),
|
||||||
|
path("<uuid:complaint_id>/investigate/review/<str:token>/", champion_review_answers, name="champion_review_answers"),
|
||||||
# Patient Complaint Portal (No Authentication Required)
|
# Patient Complaint Portal (No Authentication Required)
|
||||||
path("patient/<str:token>/", ui_views.patient_complaint_portal, name="patient_complaint_portal"),
|
path("patient/<str:token>/", ui_views.patient_complaint_portal, name="patient_complaint_portal"),
|
||||||
path(
|
path(
|
||||||
@ -161,6 +168,7 @@ urlpatterns = [
|
|||||||
path("departments/<uuid:pk>/edit/", ui_views.involved_department_edit, name="involved_department_edit"),
|
path("departments/<uuid:pk>/edit/", ui_views.involved_department_edit, name="involved_department_edit"),
|
||||||
path("departments/<uuid:pk>/remove/", ui_views.involved_department_remove, name="involved_department_remove"),
|
path("departments/<uuid:pk>/remove/", ui_views.involved_department_remove, name="involved_department_remove"),
|
||||||
path("departments/<uuid:pk>/response/", ui_views.involved_department_response, name="involved_department_response"),
|
path("departments/<uuid:pk>/response/", ui_views.involved_department_response, name="involved_department_response"),
|
||||||
|
path("departments/<uuid:pk>/review-response/", ui_views.involved_department_review_response, name="involved_department_review_response"),
|
||||||
# Send to Department Form
|
# Send to Department Form
|
||||||
path(
|
path(
|
||||||
"<uuid:pk>/send-to-department/", ui_views_explanation.send_to_department_form, name="send_to_department_form"
|
"<uuid:pk>/send-to-department/", ui_views_explanation.send_to_department_form, name="send_to_department_form"
|
||||||
|
|||||||
@ -30,6 +30,9 @@ urlpatterns = [
|
|||||||
path("public/track/", ui_views.public_inquiry_track, name="public_inquiry_track"),
|
path("public/track/", ui_views.public_inquiry_track, name="public_inquiry_track"),
|
||||||
path("<uuid:pk>/send-to-staff/", ui_views.inquiry_send_to_staff, name="inquiry_send_to_staff"),
|
path("<uuid:pk>/send-to-staff/", ui_views.inquiry_send_to_staff, name="inquiry_send_to_staff"),
|
||||||
path("<uuid:pk>/send-to/", ui_views.inquiry_send_to, name="inquiry_send_to"),
|
path("<uuid:pk>/send-to/", ui_views.inquiry_send_to, name="inquiry_send_to"),
|
||||||
|
path("<uuid:pk>/escalate/", ui_views.inquiry_escalate, name="inquiry_escalate"),
|
||||||
# Token-based explanation form (No Authentication Required)
|
# Token-based explanation form (No Authentication Required)
|
||||||
path("<uuid:inquiry_id>/explain/<str:token>/", views.inquiry_explanation_form, name="inquiry_explanation_form"),
|
path("<uuid:inquiry_id>/explain/<str:token>/", views.inquiry_explanation_form, name="inquiry_explanation_form"),
|
||||||
|
# Token-based department response (No Authentication Required)
|
||||||
|
path("<uuid:pk>/respond/<str:token>/", views.inquiry_respond_with_token, name="inquiry_respond_with_token"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -651,8 +651,8 @@ def export_monthly_calculations(queryset, year, month):
|
|||||||
qs = queryset.select_related(
|
qs = queryset.select_related(
|
||||||
"hospital",
|
"hospital",
|
||||||
"department",
|
"department",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
"assigned_to",
|
"assigned_to",
|
||||||
"resolved_by",
|
"resolved_by",
|
||||||
"closed_by",
|
"closed_by",
|
||||||
@ -846,10 +846,10 @@ def export_monthly_calculations(queryset, year, month):
|
|||||||
source_display = "Patient"
|
source_display = "Patient"
|
||||||
|
|
||||||
location_display = ""
|
location_display = ""
|
||||||
if c.main_section:
|
if c.legacy_main_section:
|
||||||
location_display = c.main_section.name if hasattr(c.main_section, "name") else str(c.main_section)
|
location_display = c.legacy_main_section.name if hasattr(c.legacy_main_section, "name") else str(c.legacy_main_section)
|
||||||
if c.location:
|
if c.legacy_location:
|
||||||
loc_name = c.location.name if hasattr(c.location, "name") else str(c.location)
|
loc_name = c.legacy_location.name if hasattr(c.legacy_location, "name") else str(c.legacy_location)
|
||||||
if loc_name:
|
if loc_name:
|
||||||
location_display = f"{loc_name} - {location_display}" if location_display else loc_name
|
location_display = f"{loc_name} - {location_display}" if location_display else loc_name
|
||||||
|
|
||||||
@ -1336,8 +1336,8 @@ def _build_quarterly_yearly_report(queryset, title, months_list, year=None):
|
|||||||
internal += 1
|
internal += 1
|
||||||
|
|
||||||
loc = "Other"
|
loc = "Other"
|
||||||
if c.main_section:
|
if c.legacy_main_section:
|
||||||
loc_name = c.main_section.name.lower() if hasattr(c.main_section, "name") else str(c.main_section).lower()
|
loc_name = c.legacy_main_section.name.lower() if hasattr(c.legacy_main_section, "name") else str(c.legacy_main_section).lower()
|
||||||
if "inpatient" in loc_name or "ip" in loc_name:
|
if "inpatient" in loc_name or "ip" in loc_name:
|
||||||
loc = "Inpatient"
|
loc = "Inpatient"
|
||||||
elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name:
|
elif "outpatient" in loc_name or "op" in loc_name or "clinic" in loc_name:
|
||||||
@ -2483,8 +2483,8 @@ def export_historical_excel(queryset, date_start=None, date_end=None):
|
|||||||
"patient",
|
"patient",
|
||||||
"hospital",
|
"hospital",
|
||||||
"department",
|
"department",
|
||||||
"main_section",
|
"legacy_main_section",
|
||||||
"subsection",
|
"legacy_subsection",
|
||||||
"assigned_to",
|
"assigned_to",
|
||||||
"resolved_by",
|
"resolved_by",
|
||||||
"closed_by",
|
"closed_by",
|
||||||
@ -2537,21 +2537,21 @@ def export_historical_excel(queryset, date_start=None, date_end=None):
|
|||||||
|
|
||||||
# Location (main_section or location)
|
# Location (main_section or location)
|
||||||
location_name = ""
|
location_name = ""
|
||||||
if c.main_section:
|
if c.legacy_main_section:
|
||||||
location_name = get_name_ar(c.main_section)
|
location_name = get_name_ar(c.legacy_main_section)
|
||||||
elif c.location:
|
elif c.legacy_location:
|
||||||
location_name = get_name_ar(c.location)
|
location_name = get_name_ar(c.legacy_location)
|
||||||
|
|
||||||
# Departments
|
# Departments
|
||||||
main_dept = ""
|
main_dept = ""
|
||||||
if c.main_section:
|
if c.legacy_main_section:
|
||||||
main_dept = get_name_ar(c.main_section)
|
main_dept = get_name_ar(c.legacy_main_section)
|
||||||
elif c.department:
|
elif c.department:
|
||||||
main_dept = get_name_ar(c.department)
|
main_dept = get_name_ar(c.department)
|
||||||
|
|
||||||
sub_dept = ""
|
sub_dept = ""
|
||||||
if c.subsection:
|
if c.legacy_subsection:
|
||||||
sub_dept = get_name_ar(c.subsection)
|
sub_dept = get_name_ar(c.legacy_subsection)
|
||||||
|
|
||||||
# Entered by (assigned_to or created_by)
|
# Entered by (assigned_to or created_by)
|
||||||
entered_by = ""
|
entered_by = ""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ Features:
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -45,6 +46,71 @@ class AIService:
|
|||||||
SEVERITY_CHOICES = ["low", "medium", "high", "critical"]
|
SEVERITY_CHOICES = ["low", "medium", "high", "critical"]
|
||||||
PRIORITY_CHOICES = ["low", "medium", "high"]
|
PRIORITY_CHOICES = ["low", "medium", "high"]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _repair_json(cls, text: str) -> str:
|
||||||
|
"""Attempt to repair malformed JSON from LLM responses."""
|
||||||
|
text = text.strip()
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
def fix_unterminated_strings(s):
|
||||||
|
result = []
|
||||||
|
i = 0
|
||||||
|
in_string = False
|
||||||
|
escape = False
|
||||||
|
while i < len(s):
|
||||||
|
ch = s[i]
|
||||||
|
if escape:
|
||||||
|
result.append(ch)
|
||||||
|
escape = False
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch == '\\' and in_string:
|
||||||
|
result.append(ch)
|
||||||
|
escape = True
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if ch == '"':
|
||||||
|
if in_string:
|
||||||
|
in_string = False
|
||||||
|
else:
|
||||||
|
in_string = True
|
||||||
|
result.append(ch)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if in_string and ch in ('\n', '\r'):
|
||||||
|
result.append(' ')
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
result.append(ch)
|
||||||
|
i += 1
|
||||||
|
if in_string:
|
||||||
|
result.append('"')
|
||||||
|
return ''.join(result)
|
||||||
|
|
||||||
|
text = fix_unterminated_strings(text)
|
||||||
|
|
||||||
|
open_braces = text.count('{') - text.count('}')
|
||||||
|
open_brackets = text.count('[') - text.count(']')
|
||||||
|
text += ']' * max(0, open_brackets) + '}' * max(0, open_braces)
|
||||||
|
|
||||||
|
text = re.sub(r',\s*([}\]])', r'\1', text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_json_loads(text: str):
|
||||||
|
"""Parse JSON with repair fallback."""
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
repaired = AIService._repair_json(text)
|
||||||
|
try:
|
||||||
|
return json.loads(repaired)
|
||||||
|
except json.JSONDecodeError as e2:
|
||||||
|
logger.warning(f"JSON repair failed, falling back to defaults: {e2}")
|
||||||
|
raise
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_api_key(cls) -> str:
|
def _get_api_key(cls) -> str:
|
||||||
return getattr(settings, "OPENROUTER_API_KEY", None) or cls.OPENROUTER_API_KEY
|
return getattr(settings, "OPENROUTER_API_KEY", None) or cls.OPENROUTER_API_KEY
|
||||||
@ -789,11 +855,12 @@ class AIService:
|
|||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
response_format="json_object",
|
response_format="json_object",
|
||||||
temperature=0.2, # Lower temperature for consistent classification
|
temperature=0.2,
|
||||||
|
max_tokens=2000,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse JSON response
|
# Parse JSON response
|
||||||
result = json.loads(response)
|
result = cls._safe_json_loads(response)
|
||||||
|
|
||||||
# Detect complaint type
|
# Detect complaint type
|
||||||
complaint_type = cls._detect_complaint_type(description + " " + (title or ""))
|
complaint_type = cls._detect_complaint_type(description + " " + (title or ""))
|
||||||
|
|||||||
@ -15,11 +15,11 @@ from django.conf import settings
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.organizations.models import Department, Hospital
|
from apps.organizations.models import Department, Hospital
|
||||||
from apps.organizations.services import StaffService
|
|
||||||
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
|
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
|
||||||
from apps.complaints.models import OnCallAdminSchedule
|
from apps.complaints.models import OnCallAdminSchedule
|
||||||
from apps.callcenter.models import CallRecord
|
from apps.callcenter.models import CallRecord
|
||||||
from apps.notifications.services import NotificationService
|
from apps.notifications.services import NotificationService
|
||||||
|
from apps.accounts.services import PasswordResetTokenService
|
||||||
from apps.core.decorators import px_admin_required, admin_required
|
from apps.core.decorators import px_admin_required, admin_required
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
@ -212,30 +212,28 @@ def reset_user_password(request, user_id):
|
|||||||
if request.tenant_hospital and target_user.hospital != request.tenant_hospital:
|
if request.tenant_hospital and target_user.hospital != request.tenant_hospital:
|
||||||
return JsonResponse({"error": "You can only reset passwords for users in your hospital."}, status=403)
|
return JsonResponse({"error": "You can only reset passwords for users in your hospital."}, status=403)
|
||||||
|
|
||||||
new_password = StaffService.generate_password()
|
if target_user.is_provisional:
|
||||||
target_user.set_password(new_password)
|
return JsonResponse({"error": "Use the onboarding invitation flow for provisional users."}, status=400)
|
||||||
target_user.save(update_fields=["password"])
|
|
||||||
|
|
||||||
login_url = f"{request.scheme}://{request.get_host()}/accounts/login/"
|
base_url = f"{request.scheme}://{request.get_host()}"
|
||||||
|
reset_token = PasswordResetTokenService.create_reset_token(target_user)
|
||||||
|
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
|
||||||
|
|
||||||
html_message = render_to_string(
|
html_message = render_to_string(
|
||||||
"config/emails/reset_password_email.html",
|
"config/emails/reset_password_email.html",
|
||||||
{
|
{
|
||||||
"user": target_user,
|
"user": target_user,
|
||||||
"password": new_password,
|
"reset_url": reset_url,
|
||||||
"login_url": login_url,
|
|
||||||
},
|
},
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
plain_message = (
|
plain_message = (
|
||||||
f"Dear {target_user.get_full_name()},\n\n"
|
f"Dear {target_user.get_full_name()},\n\n"
|
||||||
f"Your password has been reset by an administrator.\n\n"
|
f"Your PX360 password has been reset by an administrator.\n\n"
|
||||||
f"Your new credentials:\n"
|
f"For your security, no password is sent by email. Use the link below to set a new password:\n"
|
||||||
f"Email: {target_user.email}\n"
|
f"{reset_url}\n\n"
|
||||||
f"Password: {new_password}\n\n"
|
f"This link expires in 24 hours. If you did not expect this reset, contact your system administrator."
|
||||||
f"Please login and change your password immediately.\n"
|
|
||||||
f"Login URL: {login_url}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
NotificationService.send_email(
|
NotificationService.send_email(
|
||||||
@ -250,8 +248,7 @@ def reset_user_password(request, user_id):
|
|||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Password has been reset for {target_user.get_full_name()}. A new password has been sent to {target_user.email}.",
|
"message": f"Password reset link has been sent to {target_user.email}.",
|
||||||
"password": new_password,
|
|
||||||
"user_name": target_user.get_full_name(),
|
"user_name": target_user.get_full_name(),
|
||||||
"user_email": target_user.email,
|
"user_email": target_user.email,
|
||||||
}
|
}
|
||||||
|
|||||||
292
apps/core/management/commands/create_e2e_isolated_env.py
Normal file
292
apps/core/management/commands/create_e2e_isolated_env.py
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
Create an isolated E2E hospital that mirrors a source hospital's org hierarchy,
|
||||||
|
plus 10 role-bound test users, for Playwright QA.
|
||||||
|
|
||||||
|
Mirrors (create-if-not-exists) from the source hospital (default HH-N / Al Nuzha):
|
||||||
|
Area -> Department -> Section -> SubSection
|
||||||
|
|
||||||
|
LegacyLocation / LegacyMainSection / LegacySubSection are global reference tables
|
||||||
|
(shared by every hospital) and are intentionally NOT copied.
|
||||||
|
|
||||||
|
Department role-holders (champion/manager/supervisor) are left NULL on copy;
|
||||||
|
the E2E champion user is then bound as champion of one department.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run manage.py create_e2e_isolated_env --delete-existing
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth.models import Group
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
from apps.organizations.models import (
|
||||||
|
Area,
|
||||||
|
Department,
|
||||||
|
Hospital,
|
||||||
|
Organization,
|
||||||
|
Section,
|
||||||
|
Staff,
|
||||||
|
SubSection,
|
||||||
|
)
|
||||||
|
from apps.px_sources.models import PXSource, SourceUser
|
||||||
|
|
||||||
|
DEFAULT_PASSWORD = "Dev@123456"
|
||||||
|
DEFAULT_SOURCE_HOSPITAL_CODE = "HH-N"
|
||||||
|
E2E_ORG_CODE = "E2E-ORG"
|
||||||
|
E2E_HOSPITAL_CODE = "E2E-HOSP"
|
||||||
|
E2E_SOURCE_CODE = "E2E-TEST"
|
||||||
|
|
||||||
|
USERS_CONFIG = [
|
||||||
|
{"email": "e2e-px-admin@px360.test", "role": "PX Admin", "first": "E2E", "last": "PX Admin", "is_champion": False, "source_profile": False},
|
||||||
|
{"email": "e2e-hospital-admin@px360.test", "role": "Hospital Admin", "first": "E2E", "last": "Hospital Admin"},
|
||||||
|
{"email": "e2e-dept-manager@px360.test", "role": "Department Manager", "first": "E2E", "last": "Dept Manager"},
|
||||||
|
{"email": "e2e-px-employee@px360.test", "role": "PX Employee", "first": "E2E", "last": "PX Employee"},
|
||||||
|
{"email": "e2e-physician@px360.test", "role": "Physician", "first": "E2E", "last": "Physician"},
|
||||||
|
{"email": "e2e-nurse@px360.test", "role": "Nurse", "first": "E2E", "last": "Nurse"},
|
||||||
|
{"email": "e2e-staff@px360.test", "role": "Staff", "first": "E2E", "last": "Staff"},
|
||||||
|
{"email": "e2e-viewer@px360.test", "role": "Viewer", "first": "E2E", "last": "Viewer"},
|
||||||
|
{"email": "e2e-source-user@px360.test", "role": "PX Source User", "first": "E2E", "last": "Source User", "source_profile": True},
|
||||||
|
{"email": "e2e-champion@px360.test", "role": "Department Manager", "first": "E2E", "last": "Champion", "is_champion": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Create an isolated E2E hospital (mirrors source hospital hierarchy) + 10 role users"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="Password for test users")
|
||||||
|
parser.add_argument("--delete-existing", action="store_true", help="Delete existing E2E users + hospital first")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Preview without changes")
|
||||||
|
parser.add_argument("--source-hospital", default=DEFAULT_SOURCE_HOSPITAL_CODE, help="Source hospital code to mirror")
|
||||||
|
|
||||||
|
@transaction.atomic()
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
password = options["password"]
|
||||||
|
delete_existing = options["delete_existing"]
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
source_code = options["source_hospital"]
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("\n=== Creating Isolated E2E Environment ===\n"))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("DRY RUN - no changes\n"))
|
||||||
|
|
||||||
|
source = Hospital.objects.filter(code=source_code).first()
|
||||||
|
if not source:
|
||||||
|
self.stdout.write(self.style.ERROR(f"Source hospital '{source_code}' not found."))
|
||||||
|
return
|
||||||
|
self.stdout.write(f"Source hospital: {source.name} ({source.code})")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 0. Tear down existing E2E artifacts
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if delete_existing and not dry_run:
|
||||||
|
udel = User.objects.filter(email__endswith="@px360.test").delete()
|
||||||
|
self.stdout.write(f" Deleted {udel[0]} existing E2E user rows.")
|
||||||
|
Hospital.objects.filter(code=E2E_HOSPITAL_CODE).delete()
|
||||||
|
self.stdout.write(" Deleted existing E2E hospital (cascade removes mirrored hierarchy).")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. Organization + Hospital
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
org, _ = Organization.objects.get_or_create(code=E2E_ORG_CODE, defaults={
|
||||||
|
"name": "E2E Org",
|
||||||
|
"name_ar": "بيئة الاختبار",
|
||||||
|
"status": "active",
|
||||||
|
})
|
||||||
|
e2e, e2e_created = Hospital.objects.get_or_create(
|
||||||
|
code=E2E_HOSPITAL_CODE,
|
||||||
|
defaults={
|
||||||
|
"organization": org,
|
||||||
|
"name": "E2E Test Hospital",
|
||||||
|
"name_ar": "مستشفى الاختبار",
|
||||||
|
"display_name": "E2E Test Hospital",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Hospital: {e2e.code} ({'created' if e2e_created else 'exists'})"))
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("\nDRY RUN - would mirror hierarchy + create users.\n"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Mirror hierarchy: Area -> Department -> Section -> SubSection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Areas
|
||||||
|
area_count = 0
|
||||||
|
src_area_code_by_id = {}
|
||||||
|
for a in Area.objects.filter(hospital=source):
|
||||||
|
src_area_code_by_id[a.id] = a.code
|
||||||
|
_, created = Area.objects.get_or_create(
|
||||||
|
hospital=e2e, code=a.code,
|
||||||
|
defaults={
|
||||||
|
"name_en": a.name_en, "name_ar": a.name_ar,
|
||||||
|
"location_type": a.location_type, "status": a.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
area_count += int(created)
|
||||||
|
e2e_area_by_code = {a.code: a for a in Area.objects.filter(hospital=e2e)}
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Areas: +{area_count} new (total {len(e2e_area_by_code)})"))
|
||||||
|
|
||||||
|
# Departments (pass 1: no parent; pass 2: link parent)
|
||||||
|
dept_count = 0
|
||||||
|
e2e_dept_by_code = {}
|
||||||
|
for d in Department.objects.filter(hospital=source).select_related("area", "parent"):
|
||||||
|
e2e_area = e2e_area_by_code.get(d.area.code) if d.area_id and d.area else None
|
||||||
|
obj, created = Department.objects.get_or_create(
|
||||||
|
hospital=e2e, code=d.code,
|
||||||
|
defaults={
|
||||||
|
"name": d.name, "name_en": d.name_en, "name_ar": d.name_ar,
|
||||||
|
"hr_name": d.hr_name, "main_section": d.main_section,
|
||||||
|
"category": d.category, "location_type": d.location_type,
|
||||||
|
"sub_location": d.sub_location, "floor": d.floor,
|
||||||
|
"area": e2e_area, "phone": d.phone, "email": d.email,
|
||||||
|
"location": d.location, "status": d.status,
|
||||||
|
"old_name_en": d.old_name_en, "old_name_ar": d.old_name_ar,
|
||||||
|
"champion_email": d.champion_email,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
e2e_dept_by_code[d.code] = obj
|
||||||
|
dept_count += int(created)
|
||||||
|
# parents
|
||||||
|
parent_count = 0
|
||||||
|
for d in Department.objects.filter(hospital=source).select_related("parent"):
|
||||||
|
if d.parent_id and d.parent.code in e2e_dept_by_code:
|
||||||
|
e2e_obj = e2e_dept_by_code[d.code]
|
||||||
|
e2e_parent = e2e_dept_by_code[d.parent.code]
|
||||||
|
if e2e_obj.parent_id != e2e_parent.id:
|
||||||
|
e2e_obj.parent = e2e_parent
|
||||||
|
e2e_obj.save(update_fields=["parent"])
|
||||||
|
parent_count += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f" Departments: +{dept_count} new, {parent_count} parents linked (total {len(e2e_dept_by_code)})"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sections
|
||||||
|
sect_count = 0
|
||||||
|
e2e_sect_by_key = {} # (dept_code, section_code) -> Section
|
||||||
|
for s in Section.objects.filter(department__hospital=source).select_related("department"):
|
||||||
|
e2e_dept = e2e_dept_by_code.get(s.department.code)
|
||||||
|
if not e2e_dept:
|
||||||
|
continue
|
||||||
|
obj, created = Section.objects.get_or_create(
|
||||||
|
department=e2e_dept, code=s.code,
|
||||||
|
defaults={
|
||||||
|
"name_en": s.name_en, "name_ar": s.name_ar,
|
||||||
|
"location_type": s.location_type, "sub_location": s.sub_location,
|
||||||
|
"floor": s.floor, "display_name_en": s.display_name_en,
|
||||||
|
"display_name_ar": s.display_name_ar, "old_name_en": s.old_name_en,
|
||||||
|
"old_name_ar": s.old_name_ar, "status": s.status,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
e2e_sect_by_key[(s.department.code, s.code)] = obj
|
||||||
|
sect_count += int(created)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Sections: +{sect_count} new"))
|
||||||
|
|
||||||
|
# SubSections
|
||||||
|
sub_count = 0
|
||||||
|
for ss in SubSection.objects.filter(section__department__hospital=source).select_related("section", "section__department"):
|
||||||
|
key = (ss.section.department.code, ss.section.code)
|
||||||
|
e2e_section = e2e_sect_by_key.get(key)
|
||||||
|
if not e2e_section:
|
||||||
|
continue
|
||||||
|
_, created = SubSection.objects.get_or_create(
|
||||||
|
section=e2e_section, code=ss.code,
|
||||||
|
defaults={"name_en": ss.name_en, "name_ar": ss.name_ar, "status": ss.status},
|
||||||
|
)
|
||||||
|
sub_count += int(created)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" SubSections: +{sub_count} new"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. PXSource
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
px_source, _ = PXSource.objects.get_or_create(
|
||||||
|
code=E2E_SOURCE_CODE,
|
||||||
|
defaults={
|
||||||
|
"name_en": "E2E Test Source",
|
||||||
|
"name_ar": "مصدر اختبار E2E",
|
||||||
|
"source_type": "internal",
|
||||||
|
"contact_email": "e2e@px360.test",
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. Users (bound to E2E hospital)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
champion_user = None
|
||||||
|
created_count = 0
|
||||||
|
for cfg in USERS_CONFIG:
|
||||||
|
email = cfg["email"]
|
||||||
|
try:
|
||||||
|
group = Group.objects.get(name=cfg["role"])
|
||||||
|
except Group.DoesNotExist:
|
||||||
|
self.stdout.write(self.style.WARNING(f" SKIP {email}: group '{cfg['role']}' missing"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
"first_name": cfg["first"],
|
||||||
|
"last_name": cfg["last"],
|
||||||
|
"hospital": e2e,
|
||||||
|
"is_active": True,
|
||||||
|
"is_staff": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
user.set_password(password)
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" CREATED: {email} ({cfg['role']})"))
|
||||||
|
else:
|
||||||
|
user.groups.clear()
|
||||||
|
user.hospital = e2e
|
||||||
|
self.stdout.write(f" EXISTS: {email} ({cfg['role']})")
|
||||||
|
|
||||||
|
user.groups.add(group)
|
||||||
|
user.save(update_fields=["hospital"]) if not created else user.save()
|
||||||
|
|
||||||
|
if cfg.get("is_champion"):
|
||||||
|
champion_user = user
|
||||||
|
if cfg.get("source_profile"):
|
||||||
|
SourceUser.objects.get_or_create(
|
||||||
|
user=user, source=px_source,
|
||||||
|
defaults={"is_active": True, "hospital": e2e},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. Bind champion user as champion of one department
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if champion_user:
|
||||||
|
champ_dept = Department.objects.filter(hospital=e2e).first()
|
||||||
|
if champ_dept:
|
||||||
|
champion_staff, _ = Staff.objects.get_or_create(
|
||||||
|
user=champion_user,
|
||||||
|
defaults={
|
||||||
|
"first_name": champion_user.first_name,
|
||||||
|
"last_name": champion_user.last_name,
|
||||||
|
"hospital": e2e,
|
||||||
|
"department": champ_dept,
|
||||||
|
"status": "active",
|
||||||
|
"staff_type": "admin",
|
||||||
|
"job_title": "Department Champion",
|
||||||
|
"employee_id": "E2E-CHAMPION",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
champ_dept.champion = champion_staff
|
||||||
|
champ_dept.save(update_fields=["champion"])
|
||||||
|
champion_user.department = champ_dept
|
||||||
|
champion_user.save(update_fields=["department"])
|
||||||
|
self.stdout.write(self.style.SUCCESS(f" Champion bound to: {champ_dept.name_en or champ_dept.name}"))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"\nDone. Created {created_count} users. "
|
||||||
|
f"Users total: {User.objects.filter(email__endswith='@px360.test').count()}"
|
||||||
|
))
|
||||||
|
self.stdout.write(f"Hospital: {e2e.code} | "
|
||||||
|
f"Areas={Area.objects.filter(hospital=e2e).count()} "
|
||||||
|
f"Depts={Department.objects.filter(hospital=e2e).count()} "
|
||||||
|
f"Sections={Section.objects.filter(department__hospital=e2e).count()} "
|
||||||
|
f"SubSections={SubSection.objects.filter(section__department__hospital=e2e).count()}")
|
||||||
@ -125,11 +125,12 @@ class Command(BaseCommand):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"email": "e2e-champion@px360.test",
|
"email": "e2e-champion@px360.test",
|
||||||
"role": "Champion",
|
"role": "Department Manager",
|
||||||
"first_name": "E2E",
|
"first_name": "E2E",
|
||||||
"last_name": "Champion",
|
"last_name": "Champion",
|
||||||
"hospital": hospital,
|
"hospital": hospital,
|
||||||
"is_staff": False,
|
"is_staff": False,
|
||||||
|
"is_champion": True,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -176,6 +177,26 @@ class Command(BaseCommand):
|
|||||||
user.groups.add(group)
|
user.groups.add(group)
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
|
if config.get("is_champion"):
|
||||||
|
from apps.organizations.models import Staff, Department
|
||||||
|
dept = Department.objects.filter(hospital=config["hospital"]).first()
|
||||||
|
if dept:
|
||||||
|
champion_staff, _ = Staff.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
defaults={
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"hospital": config["hospital"],
|
||||||
|
"department": dept,
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dept.champion = champion_staff
|
||||||
|
dept.save(update_fields=["champion"])
|
||||||
|
user.department = dept
|
||||||
|
user.save(update_fields=["department"])
|
||||||
|
self.stdout.write(f" + Set as champion for {dept.name}")
|
||||||
|
|
||||||
if config.get("create_source_profile"):
|
if config.get("create_source_profile"):
|
||||||
_, created_su = SourceUser.objects.get_or_create(
|
_, created_su = SourceUser.objects.get_or_create(
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@ -93,10 +93,10 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.WARNING('\n📧 ORGANIZATIONS EMAILS\n'))
|
self.stdout.write(self.style.WARNING('\n📧 ORGANIZATIONS EMAILS\n'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._send_staff_credentials()
|
self._send_staff_password_reset()
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.stdout.write(self.style.ERROR(f'❌ Staff Credentials: {str(e)}'))
|
self.stdout.write(self.style.ERROR(f'❌ Staff Password Reset: {str(e)}'))
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
|
|
||||||
# Complaints Emails
|
# Complaints Emails
|
||||||
@ -325,7 +325,9 @@ class Command(BaseCommand):
|
|||||||
"""Example onboarding invitation email (uses template)"""
|
"""Example onboarding invitation email (uses template)"""
|
||||||
context = {
|
context = {
|
||||||
'user': type('obj', (object,), {'first_name': 'Ahmed', 'email': 'ahmed@hospital.com'})(),
|
'user': type('obj', (object,), {'first_name': 'Ahmed', 'email': 'ahmed@hospital.com'})(),
|
||||||
'invitation_url': 'https://px360.sa/onboarding/setup/abc123token',
|
'activation_url': 'https://px360.sa/onboarding/setup/abc123token',
|
||||||
|
'expires_at': 'April 12, 2026',
|
||||||
|
'days_remaining': 7,
|
||||||
}
|
}
|
||||||
html_content = render_to_string('accounts/onboarding/invitation_email.html', context)
|
html_content = render_to_string('accounts/onboarding/invitation_email.html', context)
|
||||||
text_content = f"""
|
text_content = f"""
|
||||||
@ -353,7 +355,9 @@ class Command(BaseCommand):
|
|||||||
"""Example onboarding reminder email (uses template)"""
|
"""Example onboarding reminder email (uses template)"""
|
||||||
context = {
|
context = {
|
||||||
'user': type('obj', (object,), {'first_name': 'Sara', 'email': 'sara@hospital.com'})(),
|
'user': type('obj', (object,), {'first_name': 'Sara', 'email': 'sara@hospital.com'})(),
|
||||||
'invitation_url': 'https://px360.sa/onboarding/setup/reminder456token',
|
'activation_url': 'https://px360.sa/onboarding/setup/reminder456token',
|
||||||
|
'expires_at': 'April 12, 2026',
|
||||||
|
'days_remaining': 3,
|
||||||
}
|
}
|
||||||
html_content = render_to_string('accounts/onboarding/reminder_email.html', context)
|
html_content = render_to_string('accounts/onboarding/reminder_email.html', context)
|
||||||
text_content = """
|
text_content = """
|
||||||
@ -372,7 +376,15 @@ class Command(BaseCommand):
|
|||||||
def _send_onboarding_completion(self):
|
def _send_onboarding_completion(self):
|
||||||
"""Example onboarding completion email (uses template)"""
|
"""Example onboarding completion email (uses template)"""
|
||||||
context = {
|
context = {
|
||||||
'user': type('obj', (object,), {'first_name': 'Mohammed', 'email': 'mohammed@hospital.com'})(),
|
'user': type('obj', (object,), {
|
||||||
|
'first_name': 'Mohammed',
|
||||||
|
'email': 'mohammed@hospital.com',
|
||||||
|
'get_full_name': lambda: 'Mohammed Al-Sayed',
|
||||||
|
'department': type('obj', (object,), {'name': 'Patient Experience'})(),
|
||||||
|
})(),
|
||||||
|
'user_detail_url': 'https://px360.sa/accounts/onboarding/provisional/mohammed/progress/',
|
||||||
|
'role_display': 'PX Employee',
|
||||||
|
'completed_at': 'April 5, 2026 09:30',
|
||||||
}
|
}
|
||||||
html_content = render_to_string('accounts/onboarding/completion_email.html', context)
|
html_content = render_to_string('accounts/onboarding/completion_email.html', context)
|
||||||
text_content = """
|
text_content = """
|
||||||
@ -411,30 +423,32 @@ class Command(BaseCommand):
|
|||||||
# ORGANIZATIONS EMAILS
|
# ORGANIZATIONS EMAILS
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|
||||||
def _send_staff_credentials(self):
|
def _send_staff_password_reset(self):
|
||||||
"""Example staff credentials email (uses template)"""
|
"""Example staff password reset email (uses template)"""
|
||||||
context = {
|
context = {
|
||||||
'staff_name': 'Dr. Fatima Al-Rashid',
|
'staff': type('obj', (object,), {
|
||||||
'username': 'fatima.alrashid',
|
'get_full_name': lambda: 'Dr. Fatima Al-Rashid',
|
||||||
'password': 'TempPass123!',
|
'email': 'fatima@hospital.com',
|
||||||
'login_url': 'https://px360.sa/login',
|
})(),
|
||||||
|
'user': type('obj', (object,), {'username': 'fatima.alrashid'})(),
|
||||||
|
'reset_url': 'https://px360.sa/accounts/password/reset/abc123token/',
|
||||||
}
|
}
|
||||||
html_content = render_to_string('organizations/emails/staff_credentials.html', context)
|
html_content = render_to_string('organizations/emails/staff_credentials.html', context)
|
||||||
text_content = f"""
|
text_content = f"""
|
||||||
Your PX360 Account Credentials
|
Your PX360 Account Password Setup
|
||||||
|
|
||||||
Dear Dr. Fatima Al-Rashid,
|
Dear Dr. Fatima Al-Rashid,
|
||||||
|
|
||||||
Your account has been created. Here are your login credentials:
|
Your account has been created. For your security, no password is sent by email.
|
||||||
|
|
||||||
Username: fatima.alrashid
|
Username: fatima.alrashid
|
||||||
Temporary Password: TempPass123!
|
Email: fatima@hospital.com
|
||||||
|
|
||||||
Login URL: https://px360.sa/login
|
Set password: https://px360.sa/accounts/password/reset/abc123token/
|
||||||
|
|
||||||
Please change your password after your first login.
|
This link expires in 24 hours.
|
||||||
"""
|
"""
|
||||||
self._send_email('Your PX360 Account Credentials', html_content, text_content)
|
self._send_email('Set Your PX360 Password', html_content, text_content)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# COMPLAINTS EMAILS
|
# COMPLAINTS EMAILS
|
||||||
|
|||||||
@ -28,7 +28,7 @@ from django.db import transaction
|
|||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.organizations.models import Organization, Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Organization, Hospital, LegacyLocation, LegacyMainSection, LegacySubSection
|
||||||
from apps.accounts.models import Role, User
|
from apps.accounts.models import Role, User
|
||||||
from apps.px_sources.models import PXSource
|
from apps.px_sources.models import PXSource
|
||||||
from apps.surveys.models import SurveyTemplate, SurveyQuestion, QuestionType
|
from apps.surveys.models import SurveyTemplate, SurveyQuestion, QuestionType
|
||||||
@ -120,9 +120,9 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write("-" * 70)
|
self.stdout.write("-" * 70)
|
||||||
if self.dry_run:
|
if self.dry_run:
|
||||||
hospitals = [
|
hospitals = [
|
||||||
type("Hospital", (), {"code": "NUZHA-DEV", "name": "Nuzha"})(),
|
type("Hospital", (), {"code": "HH-N", "name": "Al Nuzha"})(),
|
||||||
type("Hospital", (), {"code": "OLAYA-DEV", "name": "Olaya"})(),
|
type("Hospital", (), {"code": "HH-O", "name": "Al Olya"})(),
|
||||||
type("Hospital", (), {"code": "SUWAIDI-DEV", "name": "Suwaidi"})(),
|
type("Hospital", (), {"code": "HH-S", "name": "Al Suwaidi"})(),
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
hospitals = Hospital.objects.all()
|
hospitals = Hospital.objects.all()
|
||||||
@ -1387,26 +1387,26 @@ class Command(BaseCommand):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for loc in locations_data:
|
for loc in locations_data:
|
||||||
Location.objects.update_or_create(
|
LegacyLocation.objects.update_or_create(
|
||||||
id=loc["id"],
|
id=loc["id"],
|
||||||
defaults={"name_ar": loc["name_ar"], "name_en": loc["name_en"]},
|
defaults={"name_ar": loc["name_ar"], "name_en": loc["name_en"]},
|
||||||
)
|
)
|
||||||
self.stdout.write(f" ✓ Created/Updated: {len(locations_data)} Locations")
|
self.stdout.write(f" ✓ Created/Updated: {len(locations_data)} Locations")
|
||||||
|
|
||||||
for sec in main_sections_data:
|
for sec in main_sections_data:
|
||||||
MainSection.objects.update_or_create(
|
LegacyMainSection.objects.update_or_create(
|
||||||
id=sec["id"],
|
id=sec["id"],
|
||||||
defaults={"name_ar": sec["name_ar"], "name_en": sec["name_en"]},
|
defaults={"name_ar": sec["name_ar"], "name_en": sec["name_en"]},
|
||||||
)
|
)
|
||||||
self.stdout.write(f" ✓ Created/Updated: {len(main_sections_data)} Main Sections")
|
self.stdout.write(f" ✓ Created/Updated: {len(main_sections_data)} Main Sections")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
SubSection.objects.all().delete()
|
LegacySubSection.objects.all().delete()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.stdout.write(self.style.WARNING(" ⚠ Skipping SubSection deletion - some are referenced"))
|
self.stdout.write(self.style.WARNING(" ⚠ Skipping SubSection deletion - some are referenced"))
|
||||||
|
|
||||||
subsections_to_create = [
|
subsections_to_create = [
|
||||||
SubSection(
|
LegacySubSection(
|
||||||
internal_id=int(item["id"]),
|
internal_id=int(item["id"]),
|
||||||
name_en=item["name_en"],
|
name_en=item["name_en"],
|
||||||
name_ar=item["name_ar"],
|
name_ar=item["name_ar"],
|
||||||
@ -1415,7 +1415,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
for item in subsections_data
|
for item in subsections_data
|
||||||
]
|
]
|
||||||
SubSection.objects.bulk_create(subsections_to_create, ignore_conflicts=True)
|
LegacySubSection.objects.bulk_create(subsections_to_create, ignore_conflicts=True)
|
||||||
self.stdout.write(f" ✓ Created: {len(subsections_data)} Sub Sections")
|
self.stdout.write(f" ✓ Created: {len(subsections_data)} Sub Sections")
|
||||||
|
|
||||||
def create_roles_and_groups(self):
|
def create_roles_and_groups(self):
|
||||||
@ -2238,9 +2238,9 @@ class Command(BaseCommand):
|
|||||||
if not self.dry_run:
|
if not self.dry_run:
|
||||||
self.stdout.write(f"\n Organization: {Organization.objects.count()}")
|
self.stdout.write(f"\n Organization: {Organization.objects.count()}")
|
||||||
self.stdout.write(f" Hospitals: {Hospital.objects.count()}")
|
self.stdout.write(f" Hospitals: {Hospital.objects.count()}")
|
||||||
self.stdout.write(f" Locations: {Location.objects.count()}")
|
self.stdout.write(f" Locations: {LegacyLocation.objects.count()}")
|
||||||
self.stdout.write(f" Main Sections: {MainSection.objects.count()}")
|
self.stdout.write(f" Main Sections: {LegacyMainSection.objects.count()}")
|
||||||
self.stdout.write(f" Sub Sections: {SubSection.objects.count()}")
|
self.stdout.write(f" Sub Sections: {LegacySubSection.objects.count()}")
|
||||||
self.stdout.write(f" Roles: {Role.objects.count()}")
|
self.stdout.write(f" Roles: {Role.objects.count()}")
|
||||||
self.stdout.write(f" PX Sources: {PXSource.objects.count()}")
|
self.stdout.write(f" PX Sources: {PXSource.objects.count()}")
|
||||||
if not self.skip_surveys:
|
if not self.skip_surveys:
|
||||||
|
|||||||
24
apps/core/management/commands/test_email.py
Normal file
24
apps/core/management/commands/test_email.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Send a test email to verify SMTP configuration"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("to", help="Recipient email address")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
to = options["to"]
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject="PX360 Test Email",
|
||||||
|
message="This is a test email from PX360. If you received this, your Outlook SMTP configuration is working correctly.",
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=[to],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise CommandError(f"Failed to send email: {e}")
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Test email sent to {to}"))
|
||||||
@ -90,8 +90,8 @@ class TenantMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
class DepartmentRespondentMiddleware(MiddlewareMixin):
|
class DepartmentRespondentMiddleware(MiddlewareMixin):
|
||||||
"""
|
"""
|
||||||
Restrict Department Respondent users to only their department detail page
|
Restrict Department Respondent users to only their department detail page,
|
||||||
and inquiry department response pages.
|
inquiry department response pages, and observation department response pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ALLOWED_PATH_PREFIXES = [
|
ALLOWED_PATH_PREFIXES = [
|
||||||
@ -102,6 +102,7 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
|
|||||||
"/core/select-hospital/",
|
"/core/select-hospital/",
|
||||||
"/organizations/departments/",
|
"/organizations/departments/",
|
||||||
"/inquiries/",
|
"/inquiries/",
|
||||||
|
"/observations/",
|
||||||
"/api/",
|
"/api/",
|
||||||
"/health/",
|
"/health/",
|
||||||
"/admin/",
|
"/admin/",
|
||||||
@ -131,7 +132,7 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
|
|||||||
for prefix in self.ALLOWED_PATH_PREFIXES:
|
for prefix in self.ALLOWED_PATH_PREFIXES:
|
||||||
if path.startswith(prefix):
|
if path.startswith(prefix):
|
||||||
if path.startswith("/organizations/departments/"):
|
if path.startswith("/organizations/departments/"):
|
||||||
if "set-respondent" in path or "edit" in path or "delete" in path:
|
if "set-champion" in path or "edit" in path or "delete" in path:
|
||||||
from django.http import HttpResponseForbidden
|
from django.http import HttpResponseForbidden
|
||||||
|
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
@ -153,6 +154,36 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
|
|||||||
|
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
return None
|
return None
|
||||||
|
if path.startswith("/observations/"):
|
||||||
|
if path.endswith("/department-response/"):
|
||||||
|
from apps.observations.models import Observation
|
||||||
|
|
||||||
|
pk = view_kwargs.get("pk")
|
||||||
|
if pk:
|
||||||
|
try:
|
||||||
|
observation = Observation.objects.get(pk=pk)
|
||||||
|
user_dept = request.user.department
|
||||||
|
if observation.assigned_department == user_dept:
|
||||||
|
return None
|
||||||
|
except Observation.DoesNotExist:
|
||||||
|
pass
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
pk = view_kwargs.get("pk")
|
||||||
|
if pk:
|
||||||
|
from apps.observations.models import Observation
|
||||||
|
|
||||||
|
try:
|
||||||
|
observation = Observation.objects.get(pk=pk)
|
||||||
|
user_dept = request.user.department
|
||||||
|
if observation.assigned_department == user_dept:
|
||||||
|
return None
|
||||||
|
except Observation.DoesNotExist:
|
||||||
|
pass
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
|
|
||||||
|
return HttpResponseForbidden()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
from django.http import HttpResponseForbidden
|
from django.http import HttpResponseForbidden
|
||||||
|
|||||||
35
apps/core/migrations/0002_add_note_model.py
Normal file
35
apps/core/migrations/0002_add_note_model.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-12 18:28
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
|
('core', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Note',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('object_id', models.UUIDField()),
|
||||||
|
('note', models.TextField()),
|
||||||
|
('is_internal', models.BooleanField(default=True)),
|
||||||
|
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['content_type', 'object_id'], name='core_note_content_948980_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
apps/core/migrations/0003_referencesequence.py
Normal file
30
apps/core/migrations/0003_referencesequence.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-14 10:48
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_add_note_model'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReferenceSequence',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('prefix', models.CharField(db_index=True, help_text='Module prefix, e.g. CMP/INQ/OBS/APR/SGT', max_length=8)),
|
||||||
|
('hospital_token', models.CharField(db_index=True, help_text='Sanitized hospital code', max_length=24)),
|
||||||
|
('year_month', models.CharField(db_index=True, help_text='YYYYMM', max_length=6)),
|
||||||
|
('last_number', models.IntegerField(default=0)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['prefix', 'hospital_token', 'year_month'], name='core_refere_prefix_a449a6_idx')],
|
||||||
|
'unique_together': {('prefix', 'hospital_token', 'year_month')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -159,6 +159,30 @@ class SeverityChoices(BaseChoices):
|
|||||||
CRITICAL = "critical", _("Critical")
|
CRITICAL = "critical", _("Critical")
|
||||||
|
|
||||||
|
|
||||||
|
class Note(UUIDModel, TimeStampedModel):
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.UUIDField()
|
||||||
|
content_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
note = models.TextField()
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="notes",
|
||||||
|
)
|
||||||
|
is_internal = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["content_type", "object_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Note by {self.created_by} on {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||||
|
|
||||||
|
|
||||||
class TenantModel(models.Model):
|
class TenantModel(models.Model):
|
||||||
"""
|
"""
|
||||||
Abstract base model for tenant-aware models.
|
Abstract base model for tenant-aware models.
|
||||||
@ -174,3 +198,43 @@ class TenantModel(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceSequence(UUIDModel, TimeStampedModel):
|
||||||
|
"""
|
||||||
|
Monotonic per-month sequence counter for reference numbers.
|
||||||
|
|
||||||
|
Keyed by (prefix, hospital_token, year_month) so that each module/hospital
|
||||||
|
combination has its own sequence that resets monthly. Incremented atomically
|
||||||
|
via select_for_update to be safe under concurrent submissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
prefix = models.CharField(max_length=8, db_index=True, help_text="Module prefix, e.g. CMP/INQ/OBS/APR/SGT")
|
||||||
|
hospital_token = models.CharField(max_length=24, db_index=True, help_text="Sanitized hospital code")
|
||||||
|
year_month = models.CharField(max_length=6, db_index=True, help_text="YYYYMM")
|
||||||
|
last_number = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("prefix", "hospital_token", "year_month")]
|
||||||
|
indexes = [models.Index(fields=["prefix", "hospital_token", "year_month"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.prefix}-{self.year_month}-{self.hospital_token} -> {self.last_number}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def next_number(cls, prefix, hospital_token, year_month):
|
||||||
|
"""Atomically allocate and return the next number in the sequence."""
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
obj, created = cls.objects.select_for_update().get_or_create(
|
||||||
|
prefix=prefix,
|
||||||
|
hospital_token=hospital_token,
|
||||||
|
year_month=year_month,
|
||||||
|
defaults={"last_number": 1},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
return 1
|
||||||
|
obj.last_number += 1
|
||||||
|
obj.save(update_fields=["last_number"])
|
||||||
|
return obj.last_number
|
||||||
|
|||||||
41
apps/core/reference.py
Normal file
41
apps/core/reference.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Unified reference-number generator.
|
||||||
|
|
||||||
|
Format: {PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ:04d}
|
||||||
|
e.g. CMP-202606-HHN-0001
|
||||||
|
|
||||||
|
PREFIX module prefix (CMP/INQ/OBS/APR/SGT)
|
||||||
|
YYYYMM creation month
|
||||||
|
HOSPITAL_TOKEN hospital.code sanitized to uppercase alphanumerics
|
||||||
|
(HH-N -> HHN, E2E-HOSP -> E2EHOSP); "GEN" when no hospital
|
||||||
|
SEQ 4-digit monthly sequence per (prefix, hospital, month)
|
||||||
|
|
||||||
|
The sequence is allocated atomically via ReferenceSequence so it is safe under
|
||||||
|
concurrent submissions. Legacy references keep their old format (no migration
|
||||||
|
of historical data); only new records use this generator.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_TOKEN_RE = re.compile(r"[^A-Z0-9]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_hospital_token(hospital) -> str:
|
||||||
|
"""Derive a clean, format-safe token from a hospital instance."""
|
||||||
|
code = getattr(hospital, "code", None) if hospital else None
|
||||||
|
if not code:
|
||||||
|
return "GEN"
|
||||||
|
token = _TOKEN_RE.sub("", str(code).upper())
|
||||||
|
return token or "GEN"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reference(prefix: str, hospital) -> str:
|
||||||
|
"""Generate a unified reference number for the given module prefix."""
|
||||||
|
from apps.core.models import ReferenceSequence
|
||||||
|
|
||||||
|
prefix = (prefix or "").upper()
|
||||||
|
token = sanitize_hospital_token(hospital)
|
||||||
|
year_month = datetime.now().strftime("%Y%m")
|
||||||
|
number = ReferenceSequence.next_number(prefix, token, year_month)
|
||||||
|
return f"{prefix}-{year_month}-{token}-{number:04d}"
|
||||||
@ -6,4 +6,4 @@ register = template.Library()
|
|||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def email_logo_url():
|
def email_logo_url():
|
||||||
return getattr(settings, "EMAIL_LOGO_URL", f"{settings.STATIC_URL}img/HH_P_H_Logo.png")
|
return getattr(settings, "EMAIL_LOGO_URL", f"{settings.STATIC_URL}img/HH_P_V_Logo(hospital)_.png")
|
||||||
|
|||||||
138
apps/core/tests.py
Normal file
138
apps/core/tests.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""
|
||||||
|
Tests for the unified reference-number generator (apps.core.reference).
|
||||||
|
|
||||||
|
Covers: hospital-token sanitization, format, per-module prefix coverage,
|
||||||
|
sequence monotonicity, None-hospital fallback, and concurrent allocation
|
||||||
|
uniqueness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase, TransactionTestCase
|
||||||
|
|
||||||
|
from apps.core.models import ReferenceSequence
|
||||||
|
from apps.core.reference import generate_reference, sanitize_hospital_token
|
||||||
|
from apps.organizations.models import Hospital, Organization
|
||||||
|
|
||||||
|
|
||||||
|
def _make_hospital(code="TEST-HOSP"):
|
||||||
|
org, _ = Organization.objects.get_or_create(code="TEST-ORG", defaults={"name": "Test Org", "status": "active"})
|
||||||
|
hospital, _ = Hospital.objects.get_or_create(
|
||||||
|
code=code,
|
||||||
|
defaults={"organization": org, "name": f"Test {code}", "status": "active"},
|
||||||
|
)
|
||||||
|
return hospital
|
||||||
|
|
||||||
|
|
||||||
|
class SanitizeTokenTest(TestCase):
|
||||||
|
def test_strips_hyphens_and_uppercases(self):
|
||||||
|
class H:
|
||||||
|
def __init__(self, code):
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
self.assertEqual(sanitize_hospital_token(H("HH-N")), "HHN")
|
||||||
|
self.assertEqual(sanitize_hospital_token(H("e2e-hosp")), "E2EHOSP")
|
||||||
|
self.assertEqual(sanitize_hospital_token(H("main-campus-1")), "MAINCAMPUS1")
|
||||||
|
|
||||||
|
def test_none_or_empty_hospital(self):
|
||||||
|
self.assertEqual(sanitize_hospital_token(None), "GEN")
|
||||||
|
self.assertEqual(sanitize_hospital_token(object()), "GEN")
|
||||||
|
|
||||||
|
class Empty:
|
||||||
|
code = ""
|
||||||
|
|
||||||
|
self.assertEqual(sanitize_hospital_token(Empty()), "GEN")
|
||||||
|
|
||||||
|
def test_strips_non_alphanumeric(self):
|
||||||
|
class H:
|
||||||
|
def __init__(self, code):
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
self.assertEqual(sanitize_hospital_token(H("NUZHA@2026")), "NUZHA2026")
|
||||||
|
self.assertEqual(sanitize_hospital_token(H(" spaced ")), "SPACED")
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateReferenceFormatTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.hospital = _make_hospital("HH-N")
|
||||||
|
|
||||||
|
def test_format_and_prefix(self):
|
||||||
|
with patch("apps.core.reference.datetime") as mock_dt:
|
||||||
|
mock_dt.now.return_value = datetime(2026, 6, 14)
|
||||||
|
for prefix in ("CMP", "INQ", "OBS", "APR", "SGT"):
|
||||||
|
ref = generate_reference(prefix, self.hospital)
|
||||||
|
self.assertRegex(
|
||||||
|
ref,
|
||||||
|
rf"^{prefix}-202606-HHN-0001$",
|
||||||
|
f"unexpected ref {ref} for prefix {prefix}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sequence_increments_monotonically(self):
|
||||||
|
refs = [generate_reference("CMP", self.hospital) for _ in range(5)]
|
||||||
|
self.assertEqual(refs, [f"CMP-202606-HHN-{i:04d}" for i in range(1, 6)])
|
||||||
|
# all unique
|
||||||
|
self.assertEqual(len(set(refs)), len(refs))
|
||||||
|
|
||||||
|
def test_each_module_has_independent_sequence(self):
|
||||||
|
with patch("apps.core.reference.datetime") as mock_dt:
|
||||||
|
mock_dt.now.return_value = datetime(2026, 6, 14)
|
||||||
|
cmp1 = generate_reference("CMP", self.hospital)
|
||||||
|
inq1 = generate_reference("INQ", self.hospital)
|
||||||
|
cmp2 = generate_reference("CMP", self.hospital)
|
||||||
|
self.assertIn("-0001", cmp1)
|
||||||
|
self.assertIn("-0001", inq1) # independent sequence
|
||||||
|
self.assertIn("-0002", cmp2)
|
||||||
|
|
||||||
|
def test_per_hospital_isolation(self):
|
||||||
|
h2 = _make_hospital("HH-A")
|
||||||
|
with patch("apps.core.reference.datetime") as mock_dt:
|
||||||
|
mock_dt.now.return_value = datetime(2026, 6, 14)
|
||||||
|
ref_n = generate_reference("CMP", self.hospital) # HHN
|
||||||
|
ref_a = generate_reference("CMP", h2) # HHA
|
||||||
|
self.assertIn("-HHN-", ref_n)
|
||||||
|
self.assertIn("-HHA-", ref_a)
|
||||||
|
self.assertIn("-0001", ref_n)
|
||||||
|
self.assertIn("-0001", ref_a) # separate counter per hospital
|
||||||
|
|
||||||
|
def test_none_hospital_fallback(self):
|
||||||
|
ref = generate_reference("CMP", None)
|
||||||
|
self.assertRegex(ref, r"^CMP-\d{6}-GEN-0001$")
|
||||||
|
|
||||||
|
def test_sequence_resets_per_month(self):
|
||||||
|
with patch("apps.core.reference.datetime") as mock_dt:
|
||||||
|
mock_dt.now.return_value = datetime(2026, 6, 14)
|
||||||
|
generate_reference("CMP", self.hospital)
|
||||||
|
generate_reference("CMP", self.hospital) # 202606 -> 2
|
||||||
|
mock_dt.now.return_value = datetime(2026, 7, 1)
|
||||||
|
july_ref = generate_reference("CMP", self.hospital)
|
||||||
|
self.assertIn("-202607-HHN-0001", july_ref)
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrencyTest(TransactionTestCase):
|
||||||
|
"""Concurrent allocation must never produce duplicate sequence numbers."""
|
||||||
|
|
||||||
|
def test_concurrent_allocations_are_unique(self):
|
||||||
|
hospital = _make_hospital("HH-S")
|
||||||
|
n = 40
|
||||||
|
results = [None] * n
|
||||||
|
barrier = threading.Barrier(n)
|
||||||
|
|
||||||
|
def worker(idx):
|
||||||
|
barrier.wait()
|
||||||
|
results[idx] = generate_reference("CMP", hospital)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join(timeout=30)
|
||||||
|
|
||||||
|
self.assertNotIn(None, results, "a worker did not produce a reference")
|
||||||
|
self.assertEqual(len(set(results)), n, "duplicate references allocated under concurrency")
|
||||||
|
# numbers should be exactly 1..n
|
||||||
|
numbers = sorted(int(r.split("-")[-1]) for r in results)
|
||||||
|
self.assertEqual(numbers, list(range(1, n + 1)))
|
||||||
|
# one sequence row for this key
|
||||||
|
self.assertEqual(ReferenceSequence.objects.filter(prefix="CMP", hospital_token="HHS").count(), 1)
|
||||||
@ -13,9 +13,11 @@ from .views import (
|
|||||||
public_observation_submit,
|
public_observation_submit,
|
||||||
public_track,
|
public_track,
|
||||||
public_track_api,
|
public_track_api,
|
||||||
|
public_set_satisfaction,
|
||||||
api_hospitals,
|
api_hospitals,
|
||||||
api_observation_categories,
|
api_observation_categories,
|
||||||
set_language
|
set_language,
|
||||||
|
add_note
|
||||||
)
|
)
|
||||||
from . import config_views
|
from . import config_views
|
||||||
|
|
||||||
@ -36,11 +38,15 @@ urlpatterns = [
|
|||||||
path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'),
|
path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'),
|
||||||
path('public/observation/submit/', public_observation_submit, name='public_observation_submit'),
|
path('public/observation/submit/', public_observation_submit, name='public_observation_submit'),
|
||||||
path('api/track/', public_track_api, name='public_track_api'),
|
path('api/track/', public_track_api, name='public_track_api'),
|
||||||
|
path('api/public/set-satisfaction/', public_set_satisfaction, name='public_set_satisfaction'),
|
||||||
path('api/hospitals/', api_hospitals, name='api_hospitals'),
|
path('api/hospitals/', api_hospitals, name='api_hospitals'),
|
||||||
path('api/observation-categories/', api_observation_categories, name='api_observation_categories'),
|
path('api/observation-categories/', api_observation_categories, name='api_observation_categories'),
|
||||||
|
|
||||||
# Language switching
|
# Language switching
|
||||||
path('set-language/', set_language, name='set_language'),
|
path('set-language/', set_language, name='set_language'),
|
||||||
|
|
||||||
|
# Notes
|
||||||
|
path('notes/add/', add_note, name='add_note'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Configuration URLs (separate app_name)
|
# Configuration URLs (separate app_name)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
@ -12,3 +14,15 @@ def get_assignable_users(hospital):
|
|||||||
.distinct()
|
.distinct()
|
||||||
.order_by("first_name", "last_name")
|
.order_by("first_name", "last_name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_public_track_url(entity_type, reference):
|
||||||
|
try:
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
site = get_current_site(None)
|
||||||
|
domain = site.domain
|
||||||
|
except Exception:
|
||||||
|
domain = "localhost:8000"
|
||||||
|
|
||||||
|
path = reverse("core:public_track")
|
||||||
|
return f"https://{domain}{path}?type={entity_type}&reference={reference}"
|
||||||
|
|||||||
@ -3,11 +3,14 @@ Core views - Health check and utility views
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib import messages
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_GET, require_POST, require_http_methods
|
||||||
|
|
||||||
|
|
||||||
@never_cache
|
@never_cache
|
||||||
@ -172,20 +175,20 @@ def public_inquiry_submit(request):
|
|||||||
Returns JSON response with reference number.
|
Returns JSON response with reference number.
|
||||||
"""
|
"""
|
||||||
from apps.complaints.models import Inquiry
|
from apps.complaints.models import Inquiry
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Hospital, Department, Section, OrgSubSection
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
# Get form data
|
|
||||||
name = request.POST.get("name", "").strip()
|
name = request.POST.get("name", "").strip()
|
||||||
email = request.POST.get("email", "").strip()
|
email = request.POST.get("email", "").strip()
|
||||||
phone = request.POST.get("phone", "").strip()
|
phone = request.POST.get("phone", "").strip()
|
||||||
hospital_id = request.POST.get("hospital")
|
hospital_id = request.POST.get("hospital")
|
||||||
|
location_type = request.POST.get("location_type", "").strip()
|
||||||
|
area_id = request.POST.get("area", "").strip()
|
||||||
category = request.POST.get("category", "").strip()
|
category = request.POST.get("category", "").strip()
|
||||||
subject = request.POST.get("subject", "").strip()
|
subject = request.POST.get("subject", "").strip()
|
||||||
message = request.POST.get("message", "").strip()
|
message = request.POST.get("message", "").strip()
|
||||||
location_id = request.POST.get("location", "").strip()
|
department_id = request.POST.get("department", "").strip()
|
||||||
main_section_id = request.POST.get("main_section", "").strip()
|
section_id = request.POST.get("section", "").strip()
|
||||||
subsection_id = request.POST.get("subsection", "").strip()
|
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
errors = []
|
errors = []
|
||||||
@ -206,11 +209,12 @@ def public_inquiry_submit(request):
|
|||||||
# Validate hospital
|
# Validate hospital
|
||||||
hospital = Hospital.objects.get(id=hospital_id)
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
|
|
||||||
location = Location.objects.filter(id=location_id).first() if location_id else None
|
from apps.organizations.models import Area
|
||||||
main_section = MainSection.objects.filter(id=main_section_id).first() if main_section_id else None
|
|
||||||
subsection = SubSection.objects.filter(id=subsection_id).first() if subsection_id else None
|
department = Department.objects.filter(id=department_id).first() if department_id else None
|
||||||
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
||||||
|
area = Area.objects.filter(id=area_id).first() if area_id else None
|
||||||
|
|
||||||
# Create inquiry (using correct field names from model)
|
|
||||||
inquiry = Inquiry.objects.create(
|
inquiry = Inquiry.objects.create(
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
contact_name=name,
|
contact_name=name,
|
||||||
@ -220,18 +224,18 @@ def public_inquiry_submit(request):
|
|||||||
message=message,
|
message=message,
|
||||||
category=category,
|
category=category,
|
||||||
status="open",
|
status="open",
|
||||||
location=location,
|
area=area,
|
||||||
main_section=main_section,
|
department=department,
|
||||||
subsection=subsection,
|
section=section,
|
||||||
|
location_type=location_type if location_type else "",
|
||||||
)
|
)
|
||||||
|
|
||||||
reference_number = f"INQ-{str(inquiry.id)[:8].upper()}"
|
reference_number = inquiry.reference_number # generated by Inquiry.save() (unified format)
|
||||||
inquiry.reference_number = reference_number
|
|
||||||
inquiry.save(update_fields=["reference_number"])
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apps.complaints.tasks import analyze_inquiry_with_ai
|
from apps.complaints.tasks import analyze_inquiry_with_ai, notify_staff_new_item
|
||||||
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
analyze_inquiry_with_ai.delay(str(inquiry.id))
|
||||||
|
notify_staff_new_item.delay("inquiry", str(inquiry.id))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -252,7 +256,7 @@ def public_inquiry_submit(request):
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
subject = f"New Public Inquiry - {reference_number}"
|
email_subject = f"New Public Inquiry - {reference_number}"
|
||||||
html_message = render_to_string(
|
html_message = render_to_string(
|
||||||
"emails/public_inquiry_notification.html",
|
"emails/public_inquiry_notification.html",
|
||||||
{
|
{
|
||||||
@ -264,7 +268,7 @@ def public_inquiry_submit(request):
|
|||||||
)
|
)
|
||||||
plain_message = f"Inquiry from {name}\n\nSubject: {subject}\n\nMessage:\n{message}"
|
plain_message = f"Inquiry from {name}\n\nSubject: {subject}\n\nMessage:\n{message}"
|
||||||
send_mail(
|
send_mail(
|
||||||
subject=subject,
|
subject=email_subject,
|
||||||
message=plain_message,
|
message=plain_message,
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
recipient_list=[settings.DEFAULT_FROM_EMAIL],
|
recipient_list=[settings.DEFAULT_FROM_EMAIL],
|
||||||
@ -383,7 +387,10 @@ def public_track_api(request):
|
|||||||
"""
|
"""
|
||||||
API endpoint for unified tracking.
|
API endpoint for unified tracking.
|
||||||
|
|
||||||
Accepts type (complaint/inquiry/observation) and reference parameters.
|
Accepts an optional type (complaint/inquiry/observation) and a reference.
|
||||||
|
If type is omitted, it is auto-detected from the reference prefix
|
||||||
|
(CMP/INQ/OBS). APR and SGT references are internal-only and not tracked.
|
||||||
|
|
||||||
Returns standardized JSON with tracking information.
|
Returns standardized JSON with tracking information.
|
||||||
"""
|
"""
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
@ -391,8 +398,25 @@ def public_track_api(request):
|
|||||||
track_type = request.GET.get("type", "").strip().lower()
|
track_type = request.GET.get("type", "").strip().lower()
|
||||||
reference = request.GET.get("reference", "").strip()
|
reference = request.GET.get("reference", "").strip()
|
||||||
|
|
||||||
if not track_type or not reference:
|
if not reference:
|
||||||
return JsonResponse({"found": False, "error": str(_("Type and reference are required."))}, status=400)
|
return JsonResponse({"found": False, "error": str(_("A reference number is required."))}, status=400)
|
||||||
|
|
||||||
|
# Auto-detect type from prefix when not provided
|
||||||
|
if not track_type:
|
||||||
|
upper = reference.upper()
|
||||||
|
if upper.startswith("CMP-"):
|
||||||
|
track_type = "complaint"
|
||||||
|
elif upper.startswith("INQ-"):
|
||||||
|
track_type = "inquiry"
|
||||||
|
elif upper.startswith("OBS-"):
|
||||||
|
track_type = "observation"
|
||||||
|
elif upper.startswith(("APR-", "SGT-")):
|
||||||
|
return JsonResponse(
|
||||||
|
{"found": False, "error": str(_("This reference type is not publicly trackable."))},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JsonResponse({"found": False, "error": str(_("Unrecognized reference format."))}, status=400)
|
||||||
|
|
||||||
if track_type == "complaint":
|
if track_type == "complaint":
|
||||||
return _track_complaint(reference)
|
return _track_complaint(reference)
|
||||||
@ -405,11 +429,11 @@ def public_track_api(request):
|
|||||||
|
|
||||||
|
|
||||||
def _track_complaint(reference):
|
def _track_complaint(reference):
|
||||||
from apps.complaints.models import Complaint
|
from apps.complaints.models import Complaint, ComplaintInvolvedDepartment
|
||||||
|
|
||||||
try:
|
try:
|
||||||
complaint = (
|
complaint = (
|
||||||
Complaint.objects.select_related("hospital", "department", "location")
|
Complaint.objects.select_related("hospital", "department", "legacy_location")
|
||||||
.prefetch_related("updates")
|
.prefetch_related("updates")
|
||||||
.get(reference_number__iexact=reference)
|
.get(reference_number__iexact=reference)
|
||||||
)
|
)
|
||||||
@ -420,19 +444,29 @@ def _track_complaint(reference):
|
|||||||
ps = complaint.public_status
|
ps = complaint.public_status
|
||||||
|
|
||||||
public_updates = list(
|
public_updates = list(
|
||||||
complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"])
|
complaint.updates.filter(update_type__in=["status_change", "resolution"])
|
||||||
.order_by("-created_at")[:20]
|
.order_by("-created_at")[:20]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_status_map = {
|
||||||
|
"open": "Received", "in_progress": "In Progress",
|
||||||
|
"partially_resolved": "In Progress", "contacted": "In Progress",
|
||||||
|
"contacted_no_response": "In Progress", "resolved": "Resolved",
|
||||||
|
"closed": "Closed", "cancelled": "Cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
timeline = []
|
timeline = []
|
||||||
for u in public_updates:
|
for u in public_updates:
|
||||||
icon = "refresh-cw" if u.update_type == "status_change" else ("check-circle-2" if u.update_type == "resolution" else "message-square")
|
icon = "refresh-cw" if u.update_type == "status_change" else "check-circle-2"
|
||||||
title = "Status Updated" if u.update_type == "status_change" else ("Final Resolution" if u.update_type == "resolution" else "Update Received")
|
title = "Status Updated" if u.update_type == "status_change" else "Final Resolution"
|
||||||
|
msg = u.message or ""
|
||||||
|
for internal, public_label in _status_map.items():
|
||||||
|
msg = msg.replace(internal, public_label)
|
||||||
timeline.append({
|
timeline.append({
|
||||||
"type": u.update_type,
|
"type": u.update_type,
|
||||||
"icon": icon,
|
"icon": icon,
|
||||||
"title": title,
|
"title": title,
|
||||||
"comment": u.comments or "",
|
"comment": msg,
|
||||||
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
|
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -450,6 +484,10 @@ def _track_complaint(reference):
|
|||||||
else:
|
else:
|
||||||
info_cards.append({"icon": "tag", "label": "Category", "value": complaint.get_category_display() if hasattr(complaint, 'get_category_display') else "General"})
|
info_cards.append({"icon": "tag", "label": "Category", "value": complaint.get_category_display() if hasattr(complaint, 'get_category_display') else "General"})
|
||||||
|
|
||||||
|
response = {"has_response": False, "en": "", "ar": ""}
|
||||||
|
if complaint.status in ("resolved", "closed") and complaint.resolution:
|
||||||
|
response = {"has_response": True, "en": complaint.resolution, "ar": ""}
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"found": True,
|
"found": True,
|
||||||
"type": "complaint",
|
"type": "complaint",
|
||||||
@ -461,11 +499,14 @@ def _track_complaint(reference):
|
|||||||
"escalated": bool(complaint.escalated_at),
|
"escalated": bool(complaint.escalated_at),
|
||||||
"info_cards": info_cards,
|
"info_cards": info_cards,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
|
"response": response,
|
||||||
|
"satisfaction": complaint.satisfaction or "",
|
||||||
|
"satisfaction_set_at": complaint.satisfaction_set_at.strftime("%Y-%m-%d %H:%M") if complaint.satisfaction_set_at else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _track_inquiry(reference):
|
def _track_inquiry(reference):
|
||||||
from apps.complaints.models import Inquiry, InquiryUpdate
|
from apps.complaints.models import Inquiry
|
||||||
|
|
||||||
inquiry = Inquiry.objects.filter(reference_number__iexact=reference).select_related("hospital", "department").first()
|
inquiry = Inquiry.objects.filter(reference_number__iexact=reference).select_related("hospital", "department").first()
|
||||||
if not inquiry:
|
if not inquiry:
|
||||||
@ -474,25 +515,19 @@ def _track_inquiry(reference):
|
|||||||
status_map = {
|
status_map = {
|
||||||
"open": {"label": "Received", "progress": 15, "css": "amber"},
|
"open": {"label": "Received", "progress": 15, "css": "amber"},
|
||||||
"in_progress": {"label": "In Progress", "progress": 50, "css": "blue"},
|
"in_progress": {"label": "In Progress", "progress": 50, "css": "blue"},
|
||||||
"contacted": {"label": "In Progress", "progress": 50, "css": "blue"},
|
|
||||||
"contacted_no_response": {"label": "In Progress", "progress": 50, "css": "blue"},
|
|
||||||
"resolved": {"label": "Resolved", "progress": 100, "css": "emerald"},
|
"resolved": {"label": "Resolved", "progress": 100, "css": "emerald"},
|
||||||
"closed": {"label": "Closed", "progress": 100, "css": "slate"},
|
"closed": {"label": "Closed", "progress": 100, "css": "slate"},
|
||||||
}
|
}
|
||||||
sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"})
|
sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"})
|
||||||
|
|
||||||
updates = InquiryUpdate.objects.filter(inquiry=inquiry).select_related("created_by").order_by("-created_at")[:20]
|
|
||||||
|
|
||||||
timeline = []
|
timeline = []
|
||||||
for u in updates:
|
if inquiry.status in ("resolved", "closed") and (inquiry.department_response_en or inquiry.department_response_ar):
|
||||||
icon = "refresh-cw" if u.update_type == "status_change" else ("check-circle-2" if u.update_type == "response" else "message-square")
|
|
||||||
title = "Status Updated" if u.update_type == "status_change" else ("Response Received" if u.update_type == "response" else "Update Received")
|
|
||||||
timeline.append({
|
timeline.append({
|
||||||
"type": u.update_type,
|
"type": "response",
|
||||||
"icon": icon,
|
"icon": "check-circle-2",
|
||||||
"title": title,
|
"title": "Response Sent",
|
||||||
"comment": u.message or "",
|
"comment": "",
|
||||||
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
|
"created_at": (inquiry.department_responded_at or inquiry.updated_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
})
|
})
|
||||||
|
|
||||||
info_cards = [
|
info_cards = [
|
||||||
@ -520,6 +555,11 @@ def _track_inquiry(reference):
|
|||||||
"escalated": bool(inquiry.escalated_at),
|
"escalated": bool(inquiry.escalated_at),
|
||||||
"info_cards": info_cards,
|
"info_cards": info_cards,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
|
"response": {
|
||||||
|
"has_response": bool(inquiry.department_response_en or inquiry.department_response_ar),
|
||||||
|
"en": inquiry.department_response_en or "",
|
||||||
|
"ar": inquiry.department_response_ar or "",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -527,30 +567,28 @@ def _track_observation(reference):
|
|||||||
from apps.observations.models import Observation
|
from apps.observations.models import Observation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
observation = Observation.objects.select_related("hospital", "category").get(tracking_code__iexact=reference)
|
observation = Observation.objects.select_related("hospital", "category").prefetch_related("status_logs", "notes").get(tracking_code__iexact=reference)
|
||||||
except Observation.DoesNotExist:
|
except Observation.DoesNotExist:
|
||||||
return JsonResponse({"found": False, "error": "Observation not found"})
|
return JsonResponse({"found": False, "error": "Observation not found"})
|
||||||
|
|
||||||
status_progress = {
|
status_progress = {
|
||||||
"new": 15, "triaged": 30, "assigned": 40, "in_progress": 50,
|
"open": 15, "in_progress": 50,
|
||||||
"resolved": 100, "closed": 100, "rejected": 0, "duplicate": 0,
|
"resolved": 100, "closed": 100,
|
||||||
}
|
}
|
||||||
status_css = {
|
status_css = {
|
||||||
"new": "sky", "triaged": "teal", "assigned": "teal", "in_progress": "blue",
|
"open": "amber", "in_progress": "blue",
|
||||||
"resolved": "emerald", "closed": "slate", "rejected": "rose", "duplicate": "slate",
|
"resolved": "emerald", "closed": "slate",
|
||||||
}
|
}
|
||||||
|
|
||||||
timeline = []
|
timeline = []
|
||||||
if hasattr(observation, 'public_timeline') and callable(observation.public_timeline):
|
if observation.status in ("resolved", "closed") and (observation.department_response_en or observation.department_response_ar):
|
||||||
for item in observation.public_timeline:
|
timeline.append({
|
||||||
icon = "refresh-cw" if item.get("type") == "status_change" else ("message-square" if item.get("type") == "note" else "check-circle-2")
|
"type": "response",
|
||||||
timeline.append({
|
"icon": "check-circle-2",
|
||||||
"type": item.get("type", "note"),
|
"title": "Response Sent",
|
||||||
"icon": icon,
|
"comment": "",
|
||||||
"title": "Status Updated" if item.get("type") == "status_change" else ("Update Received" if item.get("type") == "note" else "Final Resolution"),
|
"created_at": (observation.department_responded_at or observation.updated_at).strftime("%Y-%m-%d %H:%M"),
|
||||||
"comment": item.get("comment", ""),
|
})
|
||||||
"created_at": item.get("created_at", ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
info_cards = [
|
info_cards = [
|
||||||
{"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")},
|
{"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")},
|
||||||
@ -569,6 +607,11 @@ def _track_observation(reference):
|
|||||||
"escalated": False,
|
"escalated": False,
|
||||||
"info_cards": info_cards,
|
"info_cards": info_cards,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
|
"response": {
|
||||||
|
"has_response": bool(observation.department_response_en or observation.department_response_ar),
|
||||||
|
"en": observation.department_response_en or "",
|
||||||
|
"ar": observation.department_response_ar or "",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@ -580,22 +623,20 @@ def public_observation_submit(request):
|
|||||||
Creates an observation from public submission.
|
Creates an observation from public submission.
|
||||||
Returns JSON response with tracking code.
|
Returns JSON response with tracking code.
|
||||||
"""
|
"""
|
||||||
from apps.observations.models import Observation, ObservationAttachment, ObservationCategory
|
from apps.observations.models import Observation, ObservationAttachment
|
||||||
from django.shortcuts import get_object_or_404
|
|
||||||
from apps.observations.services import ObservationService
|
from apps.observations.services import ObservationService
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Hospital, Department, Section, OrgSubSection
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
# Get form data
|
|
||||||
hospital_id = request.POST.get("hospital", "").strip()
|
hospital_id = request.POST.get("hospital", "").strip()
|
||||||
category_id = request.POST.get("category")
|
|
||||||
severity = request.POST.get("severity", "medium")
|
severity = request.POST.get("severity", "medium")
|
||||||
title = request.POST.get("title", "").strip()
|
title = request.POST.get("title", "").strip()
|
||||||
description = request.POST.get("description", "").strip()
|
description = request.POST.get("description", "").strip()
|
||||||
location_text = request.POST.get("location_text", "").strip()
|
location_text = request.POST.get("location_text", "").strip()
|
||||||
location_id = request.POST.get("location", "").strip()
|
location_type = request.POST.get("location_type", "").strip()
|
||||||
main_section_id = request.POST.get("main_section", "").strip()
|
area_id = request.POST.get("area", "").strip()
|
||||||
subsection_id = request.POST.get("subsection", "").strip()
|
department_id = request.POST.get("department", "").strip()
|
||||||
|
section_id = request.POST.get("section", "").strip()
|
||||||
incident_datetime = request.POST.get("incident_datetime", "")
|
incident_datetime = request.POST.get("incident_datetime", "")
|
||||||
reporter_staff_id = request.POST.get("reporter_staff_id", "").strip()
|
reporter_staff_id = request.POST.get("reporter_staff_id", "").strip()
|
||||||
reporter_name = request.POST.get("reporter_name", "").strip()
|
reporter_name = request.POST.get("reporter_name", "").strip()
|
||||||
@ -618,13 +659,11 @@ def public_observation_submit(request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
hospital = Hospital.objects.get(id=hospital_id)
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
category = None
|
|
||||||
if category_id:
|
|
||||||
category = get_object_or_404(ObservationCategory, id=category_id)
|
|
||||||
|
|
||||||
location = Location.objects.filter(id=location_id).first() if location_id else None
|
department = Department.objects.filter(id=department_id).first() if department_id else None
|
||||||
main_section = MainSection.objects.filter(id=main_section_id).first() if main_section_id else None
|
section = Section.objects.filter(id=section_id).first() if section_id else None
|
||||||
subsection = SubSection.objects.filter(id=subsection_id).first() if subsection_id else None
|
from apps.organizations.models import Area
|
||||||
|
area = Area.objects.filter(id=area_id).first() if area_id else None
|
||||||
|
|
||||||
# Get client info
|
# Get client info
|
||||||
def get_client_ip(req):
|
def get_client_ip(req):
|
||||||
@ -645,13 +684,14 @@ def public_observation_submit(request):
|
|||||||
observation = ObservationService.create_observation(
|
observation = ObservationService.create_observation(
|
||||||
description=description,
|
description=description,
|
||||||
severity=severity,
|
severity=severity,
|
||||||
category=category,
|
category=None,
|
||||||
title=title,
|
title=title,
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
location_text=location_text,
|
location_text=location_text,
|
||||||
location=location,
|
location_type=location_type,
|
||||||
main_section=main_section,
|
assigned_department=department,
|
||||||
subsection=subsection,
|
section=section,
|
||||||
|
area=area,
|
||||||
incident_datetime=incident_datetime if incident_datetime else None,
|
incident_datetime=incident_datetime if incident_datetime else None,
|
||||||
reporter_staff_id=reporter_staff_id,
|
reporter_staff_id=reporter_staff_id,
|
||||||
reporter_name=reporter_name,
|
reporter_name=reporter_name,
|
||||||
@ -665,6 +705,71 @@ def public_observation_submit(request):
|
|||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
{"success": True, "tracking_code": observation.tracking_code, "observation_id": str(observation.id)}
|
{"success": True, "tracking_code": observation.tracking_code, "observation_id": str(observation.id)}
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({"success": False, "errors": [str(e)]}, status=500)
|
return JsonResponse({"success": False, "errors": [str(e)]}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def add_note(request):
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from apps.core.models import Note
|
||||||
|
|
||||||
|
content_type_id = request.POST.get("content_type_id")
|
||||||
|
object_id = request.POST.get("object_id")
|
||||||
|
note_text = request.POST.get("note", "").strip()
|
||||||
|
|
||||||
|
if not note_text:
|
||||||
|
messages.error(request, _("Note cannot be empty."))
|
||||||
|
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
ct = ContentType.objects.get(pk=content_type_id)
|
||||||
|
obj = ct.get_object_for_this_type(pk=object_id)
|
||||||
|
except Exception:
|
||||||
|
messages.error(request, _("Invalid object reference."))
|
||||||
|
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
Note.objects.create(
|
||||||
|
content_type=ct,
|
||||||
|
object_id=object_id,
|
||||||
|
note=note_text,
|
||||||
|
created_by=request.user,
|
||||||
|
is_internal=True,
|
||||||
|
)
|
||||||
|
messages.success(request, _("Note added successfully."))
|
||||||
|
return redirect(request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
@csrf_exempt
|
||||||
|
def public_set_satisfaction(request):
|
||||||
|
"""Public endpoint to set patient satisfaction for a complaint (no auth required)."""
|
||||||
|
from django.utils import timezone
|
||||||
|
from apps.complaints.models import Complaint
|
||||||
|
|
||||||
|
reference = request.POST.get("reference", "").strip()
|
||||||
|
satisfaction = request.POST.get("satisfaction", "").strip()
|
||||||
|
|
||||||
|
if not reference or not satisfaction:
|
||||||
|
return JsonResponse({"success": False, "error": "Reference and satisfaction are required."}, status=400)
|
||||||
|
|
||||||
|
valid_choices = ["satisfied", "neutral", "dissatisfied"]
|
||||||
|
if satisfaction not in valid_choices:
|
||||||
|
return JsonResponse({"success": False, "error": "Invalid satisfaction value."}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
complaint = Complaint.objects.get(reference_number__iexact=reference)
|
||||||
|
except Complaint.DoesNotExist:
|
||||||
|
return JsonResponse({"success": False, "error": "Complaint not found."}, status=404)
|
||||||
|
|
||||||
|
if complaint.status not in ("resolved", "closed") or not complaint.resolution:
|
||||||
|
return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved complaints."}, status=400)
|
||||||
|
|
||||||
|
complaint.satisfaction = satisfaction
|
||||||
|
complaint.satisfaction_set_at = timezone.now()
|
||||||
|
complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
|
||||||
|
|
||||||
|
return JsonResponse({"success": True, "satisfaction": complaint.satisfaction})
|
||||||
|
|||||||
@ -201,7 +201,7 @@ def _write_data_rows(ws, queryset):
|
|||||||
c.reference_number or "",
|
c.reference_number or "",
|
||||||
c.file_number or "",
|
c.file_number or "",
|
||||||
c.source.name_en if c.source else (c.complaint_source_type or ""),
|
c.source.name_en if c.source else (c.complaint_source_type or ""),
|
||||||
c.location.name if c.location else "",
|
c.department.name_en if c.department else "",
|
||||||
c.domain.name_en if c.domain else "",
|
c.domain.name_en if c.domain else "",
|
||||||
c.category.name_en if c.category else "",
|
c.category.name_en if c.category else "",
|
||||||
c.created_at,
|
c.created_at,
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class ComplaintMonthlyService:
|
|||||||
).select_related(
|
).select_related(
|
||||||
"patient", "department", "assigned_to", "created_by",
|
"patient", "department", "assigned_to", "created_by",
|
||||||
"domain", "category", "subcategory_obj",
|
"domain", "category", "subcategory_obj",
|
||||||
"location", "main_section", "source",
|
"source",
|
||||||
).prefetch_related("involved_departments__department")
|
).prefetch_related("involved_departments__department")
|
||||||
|
|
||||||
def get_summary(self):
|
def get_summary(self):
|
||||||
|
|||||||
@ -280,12 +280,9 @@ def _write_source_table_sheet(wb, service):
|
|||||||
int_total = sum(source[m]["internal"] for m in active_months)
|
int_total = sum(source[m]["internal"] for m in active_months)
|
||||||
|
|
||||||
row = 2
|
row = 2
|
||||||
ins_total = sum(source[m]["insurance"] for m in active_months)
|
_style_data(ws, row, 1, round(ext_total / total_all, 3) if total_all else 0, PCT_FMT)
|
||||||
_write_toprow(ws, row,
|
_style_data(ws, row, 2, ext_total)
|
||||||
round(ext_total / total_all, 3) if total_all else 0, ext_total, "External",
|
_style_data(ws, row, 3, "External")
|
||||||
"Insurance company", ins_total,
|
|
||||||
lambda m: source[m]["insurance"],
|
|
||||||
lambda m: round(source[m]["insurance"] / source[m]["total"], 3) if source[m]["total"] else 0)
|
|
||||||
|
|
||||||
row = 3
|
row = 3
|
||||||
moh_total = sum(source[m]["moh"] for m in active_months)
|
moh_total = sum(source[m]["moh"] for m in active_months)
|
||||||
@ -335,7 +332,7 @@ def _write_source_table_sheet(wb, service):
|
|||||||
_write_total_row(ws, row, total_all, {m: source[m]["total"] for m in active_months})
|
_write_total_row(ws, row, total_all, {m: source[m]["total"] for m in active_months})
|
||||||
|
|
||||||
row += 2
|
row += 2
|
||||||
for label, key in [("Medical", "medical"), ("Admin", "admin"), ("Nursing", "nursing"), ("Support Services", "support")]:
|
for label, key in [("Medical", "medical"), ("Administrative", "administrative"), ("Nursing", "nursing"), ("Support Services", "support")]:
|
||||||
total_v = sum(dept_type[m][key] for m in active_months)
|
total_v = sum(dept_type[m][key] for m in active_months)
|
||||||
_style_data(ws, row, 1, round(total_v / total_all, 3) if total_all else 0, PCT_FMT)
|
_style_data(ws, row, 1, round(total_v / total_all, 3) if total_all else 0, PCT_FMT)
|
||||||
_style_data(ws, row, 2, total_v)
|
_style_data(ws, row, 2, total_v)
|
||||||
@ -353,7 +350,7 @@ def _write_source_table_sheet(wb, service):
|
|||||||
_write_total_row(ws, row, total_all, {m: source[m]["total"] for m in active_months})
|
_write_total_row(ws, row, total_all, {m: source[m]["total"] for m in active_months})
|
||||||
|
|
||||||
row += 2
|
row += 2
|
||||||
summary_headers = ["Month", "MOH Complaints", "CHI", "Insurance Company", "Internal", "Total Complaints", "MOH Percentage", "CCHI Percentage"]
|
summary_headers = ["Month", "MOH Complaints", "CHI", "Internal", "Total Complaints", "MOH Percentage", "CCHI Percentage"]
|
||||||
for i, h in enumerate(summary_headers):
|
for i, h in enumerate(summary_headers):
|
||||||
_style_header(ws, row, 5 + i, h)
|
_style_header(ws, row, 5 + i, h)
|
||||||
|
|
||||||
@ -362,19 +359,15 @@ def _write_source_table_sheet(wb, service):
|
|||||||
_style_data(ws, row, 5, ms["month"])
|
_style_data(ws, row, 5, ms["month"])
|
||||||
_style_data(ws, row, 6, ms["moh"])
|
_style_data(ws, row, 6, ms["moh"])
|
||||||
_style_data(ws, row, 7, ms["chi"])
|
_style_data(ws, row, 7, ms["chi"])
|
||||||
_style_data(ws, row, 8, ms["insurance"])
|
_style_data(ws, row, 8, ms["internal"])
|
||||||
_style_data(ws, row, 9, ms["internal"])
|
_style_data(ws, row, 9, ms["total"])
|
||||||
_style_data(ws, row, 10, ms["total"])
|
_style_data(ws, row, 10, ms["moh_pct"], PCT_FMT)
|
||||||
_style_data(ws, row, 11, ms["moh_pct"], PCT_FMT)
|
_style_data(ws, row, 11, ms["chi_pct"], PCT_FMT)
|
||||||
_style_data(ws, row, 12, ms["chi_pct"], PCT_FMT)
|
|
||||||
|
|
||||||
row += 2
|
row += 2
|
||||||
source_total_items = [
|
source_total_items = [
|
||||||
("Internal Complaints", source_totals["internal"]["count"], "% Internal", source_totals["internal"]["pct"]),
|
("Internal Complaints", source_totals["internal"]["count"], "% Internal", source_totals["internal"]["pct"]),
|
||||||
("External Complaints", source_totals["external"]["count"], "% External", source_totals["external"]["pct"]),
|
("External Complaints", source_totals["external"]["count"], "% External", source_totals["external"]["pct"]),
|
||||||
("MOH", source_totals["moh"]["count"], "% MOH", source_totals["moh"]["pct"]),
|
|
||||||
("CHI", source_totals["chi"]["count"], "% CHI", source_totals["chi"]["pct"]),
|
|
||||||
("Insurance Company", source_totals["insurance"]["count"], "% Insurance Comp.", source_totals["insurance"]["pct"]),
|
|
||||||
]
|
]
|
||||||
for label, count, pct_label, pct in source_total_items:
|
for label, count, pct_label, pct in source_total_items:
|
||||||
_style_bold(ws, row, 6, label)
|
_style_bold(ws, row, 6, label)
|
||||||
@ -398,13 +391,13 @@ def _write_source_table_sheet(wb, service):
|
|||||||
_style_data(ws, row, 5, months[i])
|
_style_data(ws, row, 5, months[i])
|
||||||
_style_data(ws, row, 6, d[f"{area}_complaints"])
|
_style_data(ws, row, 6, d[f"{area}_complaints"])
|
||||||
_style_data(ws, row, 7, d[f"{area}_patients"])
|
_style_data(ws, row, 7, d[f"{area}_patients"])
|
||||||
_style_data(ws, row, 8, d[f"{area}_ratio"], PCT_FMT)
|
_style_data(ws, row, 8, d[f"{area}_ratio"] if d[f"{area}_patients"] > 0 else "-", PCT_FMT)
|
||||||
row += 1
|
row += 1
|
||||||
t = location_ratios["totals"][area]
|
t = location_ratios["totals"][area]
|
||||||
_style_bold(ws, row, 5, "TOTAL")
|
_style_bold(ws, row, 5, "TOTAL")
|
||||||
_style_bold(ws, row, 6, t["complaints"])
|
_style_bold(ws, row, 6, t["complaints"])
|
||||||
_style_bold(ws, row, 7, t["patients"])
|
_style_bold(ws, row, 7, t["patients"])
|
||||||
_style_bold(ws, row, 8, t["ratio"], PCT_FMT)
|
_style_bold(ws, row, 8, t["ratio"] if t["patients"] > 0 else "-", PCT_FMT)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
row += 1
|
row += 1
|
||||||
@ -446,7 +439,7 @@ def _write_source_table_sheet(wb, service):
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
row += 1
|
row += 1
|
||||||
dept_headers = ["Month", "Medical", "Admin", "Nursing", "Support Services", "Total Complaints"]
|
dept_headers = ["Month", "Medical", "Administrative", "Nursing", "Support Services", "Total Complaints"]
|
||||||
for i, h in enumerate(dept_headers):
|
for i, h in enumerate(dept_headers):
|
||||||
_style_header(ws, row, 5 + i, h)
|
_style_header(ws, row, 5 + i, h)
|
||||||
|
|
||||||
@ -454,7 +447,7 @@ def _write_source_table_sheet(wb, service):
|
|||||||
row += 1
|
row += 1
|
||||||
_style_data(ws, row, 5, mr["month"])
|
_style_data(ws, row, 5, mr["month"])
|
||||||
_style_data(ws, row, 6, mr["medical"])
|
_style_data(ws, row, 6, mr["medical"])
|
||||||
_style_data(ws, row, 7, mr["admin"])
|
_style_data(ws, row, 7, mr["administrative"])
|
||||||
_style_data(ws, row, 8, mr["nursing"])
|
_style_data(ws, row, 8, mr["nursing"])
|
||||||
_style_data(ws, row, 9, mr["support"])
|
_style_data(ws, row, 9, mr["support"])
|
||||||
_style_data(ws, row, 10, mr["total"])
|
_style_data(ws, row, 10, mr["total"])
|
||||||
@ -462,7 +455,7 @@ def _write_source_table_sheet(wb, service):
|
|||||||
row += 1
|
row += 1
|
||||||
_style_bold(ws, row, 5, "TOTAL")
|
_style_bold(ws, row, 5, "TOTAL")
|
||||||
_style_bold(ws, row, 6, dept_type_monthly["totals"]["medical"])
|
_style_bold(ws, row, 6, dept_type_monthly["totals"]["medical"])
|
||||||
_style_bold(ws, row, 7, dept_type_monthly["totals"]["admin"])
|
_style_bold(ws, row, 7, dept_type_monthly["totals"]["administrative"])
|
||||||
_style_bold(ws, row, 8, dept_type_monthly["totals"]["nursing"])
|
_style_bold(ws, row, 8, dept_type_monthly["totals"]["nursing"])
|
||||||
_style_bold(ws, row, 9, dept_type_monthly["totals"]["support"])
|
_style_bold(ws, row, 9, dept_type_monthly["totals"]["support"])
|
||||||
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
|
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
|
||||||
@ -470,25 +463,25 @@ def _write_source_table_sheet(wb, service):
|
|||||||
row += 1
|
row += 1
|
||||||
_style_bold(ws, row, 5, "% From total")
|
_style_bold(ws, row, 5, "% From total")
|
||||||
_style_bold(ws, row, 6, dept_type_monthly["percentages"]["medical"], PCT_FMT)
|
_style_bold(ws, row, 6, dept_type_monthly["percentages"]["medical"], PCT_FMT)
|
||||||
_style_bold(ws, row, 7, dept_type_monthly["percentages"]["admin"], PCT_FMT)
|
_style_bold(ws, row, 7, dept_type_monthly["percentages"]["administrative"], PCT_FMT)
|
||||||
_style_bold(ws, row, 8, dept_type_monthly["percentages"]["nursing"], PCT_FMT)
|
_style_bold(ws, row, 8, dept_type_monthly["percentages"]["nursing"], PCT_FMT)
|
||||||
_style_bold(ws, row, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
|
_style_bold(ws, row, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
|
||||||
_style_bold(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
|
_style_bold(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
|
||||||
|
|
||||||
row += 2
|
row += 2
|
||||||
for i, h in enumerate(["Medical", "Admin", "Nursing", "Support Services", "Total Complaints"]):
|
for i, h in enumerate(["Medical", "Administrative", "Nursing", "Support Services", "Total Complaints"]):
|
||||||
_style_header(ws, row, 5 + i, h)
|
_style_header(ws, row, 5 + i, h)
|
||||||
row += 1
|
row += 1
|
||||||
_style_data(ws, row, 5, "Persentage")
|
_style_data(ws, row, 5, "Persentage")
|
||||||
_style_data(ws, row, 6, dept_type_monthly["percentages"]["medical"], PCT_FMT)
|
_style_data(ws, row, 6, dept_type_monthly["percentages"]["medical"], PCT_FMT)
|
||||||
_style_data(ws, row, 7, dept_type_monthly["percentages"]["admin"], PCT_FMT)
|
_style_data(ws, row, 7, dept_type_monthly["percentages"]["administrative"], PCT_FMT)
|
||||||
_style_data(ws, row, 8, dept_type_monthly["percentages"]["nursing"], PCT_FMT)
|
_style_data(ws, row, 8, dept_type_monthly["percentages"]["nursing"], PCT_FMT)
|
||||||
_style_data(ws, row, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
|
_style_data(ws, row, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
|
||||||
_style_data(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
|
_style_data(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
|
||||||
row += 1
|
row += 1
|
||||||
_style_bold(ws, row, 5, "TOTAL")
|
_style_bold(ws, row, 5, "TOTAL")
|
||||||
_style_bold(ws, row, 6, dept_type_monthly["totals"]["medical"])
|
_style_bold(ws, row, 6, dept_type_monthly["totals"]["medical"])
|
||||||
_style_bold(ws, row, 7, dept_type_monthly["totals"]["admin"])
|
_style_bold(ws, row, 7, dept_type_monthly["totals"]["administrative"])
|
||||||
_style_bold(ws, row, 8, dept_type_monthly["totals"]["nursing"])
|
_style_bold(ws, row, 8, dept_type_monthly["totals"]["nursing"])
|
||||||
_style_bold(ws, row, 9, dept_type_monthly["totals"]["support"])
|
_style_bold(ws, row, 9, dept_type_monthly["totals"]["support"])
|
||||||
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
|
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
|
||||||
@ -507,9 +500,9 @@ def _write_escalated_sheet(wb, service):
|
|||||||
|
|
||||||
categories = [
|
categories = [
|
||||||
("Medical", "medical"),
|
("Medical", "medical"),
|
||||||
("Non-Medical", "non_medical"),
|
("Administrative", "administrative"),
|
||||||
("Nursing", "nursing"),
|
("Nursing", "nursing"),
|
||||||
("Support Services", "support"),
|
("Support Services", "support_services"),
|
||||||
]
|
]
|
||||||
|
|
||||||
cat_headers = []
|
cat_headers = []
|
||||||
@ -588,16 +581,16 @@ def _write_per_department_sheet(wb, service):
|
|||||||
|
|
||||||
cat_configs = [
|
cat_configs = [
|
||||||
("Medical", "medical", 2),
|
("Medical", "medical", 2),
|
||||||
("Non-Medical", "non_medical", 12),
|
("Administrative", "administrative", 12),
|
||||||
("Nursing", "nursing", 20),
|
("Nursing", "nursing", 20),
|
||||||
("Support Services", "support", 28),
|
("Support Services", "support_services", 28),
|
||||||
]
|
]
|
||||||
|
|
||||||
for cat_label, cat_key, col_start in cat_configs:
|
for cat_label, cat_key, col_start in cat_configs:
|
||||||
_style_header(ws, 1, col_start, cat_label)
|
_style_header(ws, 1, col_start, cat_label)
|
||||||
ws.merge_cells(start_row=1, start_column=col_start, end_row=1, end_column=col_start + 7)
|
ws.merge_cells(start_row=1, start_column=col_start, end_row=1, end_column=col_start + 6)
|
||||||
|
|
||||||
sub_headers = ["Sub-dept", "MOH", "CHI", "Insurance", "Internal", "Total", "Escalated", "Response Rate (Days)"]
|
sub_headers = ["Sub-dept", "MOH", "CHI", "Internal", "Total", "Escalated", "Response Rate (Days)"]
|
||||||
for i, h in enumerate(sub_headers):
|
for i, h in enumerate(sub_headers):
|
||||||
_style_header(ws, 2, col_start + i, h)
|
_style_header(ws, 2, col_start + i, h)
|
||||||
|
|
||||||
@ -607,20 +600,18 @@ def _write_per_department_sheet(wb, service):
|
|||||||
_style_data(ws, row, col_start, d["name"])
|
_style_data(ws, row, col_start, d["name"])
|
||||||
_style_data(ws, row, col_start + 1, d["moh"])
|
_style_data(ws, row, col_start + 1, d["moh"])
|
||||||
_style_data(ws, row, col_start + 2, d["chi"])
|
_style_data(ws, row, col_start + 2, d["chi"])
|
||||||
_style_data(ws, row, col_start + 3, d["insurance"])
|
_style_data(ws, row, col_start + 3, d["internal"])
|
||||||
_style_data(ws, row, col_start + 4, d["internal"])
|
_style_data(ws, row, col_start + 4, d["total"])
|
||||||
_style_data(ws, row, col_start + 5, d["total"])
|
_style_data(ws, row, col_start + 5, d["escalated"])
|
||||||
_style_data(ws, row, col_start + 6, d["escalated"])
|
_style_data(ws, row, col_start + 6, d["avg_response_days"], NUM_FMT)
|
||||||
_style_data(ws, row, col_start + 7, d["avg_response_days"], NUM_FMT)
|
|
||||||
|
|
||||||
total_row = 3 + len(depts)
|
total_row = 3 + len(depts)
|
||||||
_style_bold(ws, total_row, col_start, "Total")
|
_style_bold(ws, total_row, col_start, "Total")
|
||||||
_style_bold(ws, total_row, col_start + 1, sum(d["moh"] for d in depts))
|
_style_bold(ws, total_row, col_start + 1, sum(d["moh"] for d in depts))
|
||||||
_style_bold(ws, total_row, col_start + 2, sum(d["chi"] for d in depts))
|
_style_bold(ws, total_row, col_start + 2, sum(d["chi"] for d in depts))
|
||||||
_style_bold(ws, total_row, col_start + 3, sum(d["insurance"] for d in depts))
|
_style_bold(ws, total_row, col_start + 3, sum(d["internal"] for d in depts))
|
||||||
_style_bold(ws, total_row, col_start + 4, sum(d["internal"] for d in depts))
|
_style_bold(ws, total_row, col_start + 4, sum(d["total"] for d in depts))
|
||||||
_style_bold(ws, total_row, col_start + 5, sum(d["total"] for d in depts))
|
_style_bold(ws, total_row, col_start + 5, sum(d["escalated"] for d in depts))
|
||||||
_style_bold(ws, total_row, col_start + 6, sum(d["escalated"] for d in depts))
|
|
||||||
|
|
||||||
summary_row = 3 + max(len(categories.get(ck, [])) for _, ck, _ in cat_configs) + 3
|
summary_row = 3 + max(len(categories.get(ck, [])) for _, ck, _ in cat_configs) + 3
|
||||||
|
|
||||||
@ -628,7 +619,6 @@ def _write_per_department_sheet(wb, service):
|
|||||||
("MOH", source_totals["moh"]),
|
("MOH", source_totals["moh"]),
|
||||||
("CHI", source_totals["chi"]),
|
("CHI", source_totals["chi"]),
|
||||||
("Internal", source_totals["internal"]),
|
("Internal", source_totals["internal"]),
|
||||||
("Insurance Co.", source_totals["insurance"]),
|
|
||||||
("Total", source_totals["total"]),
|
("Total", source_totals["total"]),
|
||||||
]
|
]
|
||||||
for label, val in source_items:
|
for label, val in source_items:
|
||||||
@ -704,8 +694,8 @@ def _write_per_dept_response_rate_sheets(wb, service):
|
|||||||
|
|
||||||
sheet_configs = [
|
sheet_configs = [
|
||||||
("8.1 Response Rate Medical", ["medical"]),
|
("8.1 Response Rate Medical", ["medical"]),
|
||||||
("8.2 Response Rate Non-Medical", ["non_medical", "admin"]),
|
("8.2 Response Rate Administrative", ["administrative", "admin"]),
|
||||||
("8.3 RR Nursing&Support", ["nursing", "support"]),
|
("8.3 RR Nursing&Support", ["nursing", "support_services"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
for sheet_title, domain_types in sheet_configs:
|
for sheet_title, domain_types in sheet_configs:
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class ComplaintQuarterlyService:
|
|||||||
hospital_id=self.hospital_id,
|
hospital_id=self.hospital_id,
|
||||||
created_at__range=(dt_start, dt_end),
|
created_at__range=(dt_start, dt_end),
|
||||||
).select_related(
|
).select_related(
|
||||||
"department", "domain", "category", "location",
|
"department",
|
||||||
"source", "assigned_to", "created_by",
|
"source", "assigned_to", "created_by",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -209,9 +209,20 @@ class ComplaintQuarterlyService:
|
|||||||
).count()
|
).count()
|
||||||
relatives = qs.filter(
|
relatives = qs.filter(
|
||||||
Q(source__name_en__icontains="relative")
|
Q(source__name_en__icontains="relative")
|
||||||
|
| Q(source__name_en__icontains="family")
|
||||||
| Q(relation_to_patient="relative")
|
| Q(relation_to_patient="relative")
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
|
by_source = list(
|
||||||
|
qs.filter(source__isnull=False)
|
||||||
|
.values("source__name_en")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
)
|
||||||
|
no_source = qs.filter(source__isnull=True).count()
|
||||||
|
if no_source > 0:
|
||||||
|
by_source.append({"source__name_en": "No Source", "count": no_source})
|
||||||
|
|
||||||
result[m] = {
|
result[m] = {
|
||||||
"total": total,
|
"total": total,
|
||||||
"external": ext,
|
"external": ext,
|
||||||
@ -221,6 +232,7 @@ class ComplaintQuarterlyService:
|
|||||||
"insurance": insurance,
|
"insurance": insurance,
|
||||||
"patients": patients,
|
"patients": patients,
|
||||||
"relatives": relatives,
|
"relatives": relatives,
|
||||||
|
"by_source": by_source,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -232,15 +244,12 @@ class ComplaintQuarterlyService:
|
|||||||
for m in self.active_months:
|
for m in self.active_months:
|
||||||
qs = self._month_qs(m)
|
qs = self._month_qs(m)
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
ip = qs.filter(
|
ip = qs.filter(department__location_type="IP").count()
|
||||||
Q(location__name_en__icontains="inpatient") | Q(location__name_en__icontains="in-patient")
|
er = qs.filter(department__location_type="ER").count()
|
||||||
).count()
|
op = qs.filter(department__location_type="OP").count()
|
||||||
er = qs.filter(
|
general = total - ip - er - op
|
||||||
Q(location__name_en__icontains="emergency") | Q(location__name_en__icontains="er")
|
|
||||||
).count()
|
|
||||||
op = total - ip - er
|
|
||||||
|
|
||||||
result[m] = {"total": total, "ip": ip, "op": op, "er": er}
|
result[m] = {"total": total, "ip": ip, "op": op, "er": er, "general": general}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_dept_type_breakdown(self):
|
def get_dept_type_breakdown(self):
|
||||||
@ -251,17 +260,19 @@ class ComplaintQuarterlyService:
|
|||||||
for m in self.active_months:
|
for m in self.active_months:
|
||||||
qs = self._month_qs(m)
|
qs = self._month_qs(m)
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
medical = qs.filter(domain__domain_type="medical").count()
|
medical = qs.filter(department__category="medical").count()
|
||||||
admin = qs.filter(domain__domain_type="admin").count()
|
administrative = qs.filter(department__category="administrative").count()
|
||||||
nursing = qs.filter(domain__domain_type="nursing").count()
|
nursing = qs.filter(department__category="nursing").count()
|
||||||
support = total - medical - admin - nursing
|
support = qs.filter(department__category="support_services").count()
|
||||||
|
other = total - medical - administrative - nursing - support
|
||||||
|
|
||||||
result[m] = {
|
result[m] = {
|
||||||
"total": total,
|
"total": total,
|
||||||
"medical": medical,
|
"medical": medical,
|
||||||
"admin": admin,
|
"administrative": administrative,
|
||||||
"nursing": nursing,
|
"nursing": nursing,
|
||||||
"support": support,
|
"support": support,
|
||||||
|
"other": other,
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -275,14 +286,22 @@ class ComplaintQuarterlyService:
|
|||||||
for c in qs.iterator(chunk_size=2000):
|
for c in qs.iterator(chunk_size=2000):
|
||||||
dept_name = c.department.name if c.department else "Unknown"
|
dept_name = c.department.name if c.department else "Unknown"
|
||||||
domain_type = "medical"
|
domain_type = "medical"
|
||||||
if c.domain:
|
if c.department and c.department.category:
|
||||||
dt = c.domain.domain_type or ""
|
dt = c.department.category
|
||||||
if dt == "admin" or dt == "non_medical":
|
if dt in ("admin", "non_medical", "administrative"):
|
||||||
domain_type = "non_medical"
|
domain_type = "administrative"
|
||||||
elif dt == "nursing":
|
elif dt == "nursing":
|
||||||
domain_type = "nursing"
|
domain_type = "nursing"
|
||||||
elif dt in ("support", "support_services"):
|
elif dt in ("support", "support_services"):
|
||||||
domain_type = "support"
|
domain_type = "support_services"
|
||||||
|
elif c.domain:
|
||||||
|
dt = c.domain.domain_type or ""
|
||||||
|
if dt in ("admin", "non_medical", "MANAGEMENT"):
|
||||||
|
domain_type = "administrative"
|
||||||
|
elif dt == "nursing":
|
||||||
|
domain_type = "nursing"
|
||||||
|
elif dt in ("support", "support_services"):
|
||||||
|
domain_type = "support_services"
|
||||||
by_category[domain_type][dept_name] += 1
|
by_category[domain_type][dept_name] += 1
|
||||||
|
|
||||||
if c.complaint_source_type == "internal":
|
if c.complaint_source_type == "internal":
|
||||||
@ -434,8 +453,8 @@ class ComplaintQuarterlyService:
|
|||||||
domain_type = c.department.category
|
domain_type = c.department.category
|
||||||
elif c.domain:
|
elif c.domain:
|
||||||
dt = c.domain.domain_type or ""
|
dt = c.domain.domain_type or ""
|
||||||
if dt == "admin" or dt == "non_medical":
|
if dt in ("admin", "non_medical", "MANAGEMENT"):
|
||||||
domain_type = "non_medical"
|
domain_type = "administrative"
|
||||||
elif dt == "nursing":
|
elif dt == "nursing":
|
||||||
domain_type = "nursing"
|
domain_type = "nursing"
|
||||||
elif dt in ("support", "support_services"):
|
elif dt in ("support", "support_services"):
|
||||||
@ -495,7 +514,7 @@ class ComplaintQuarterlyService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
avg_response = {}
|
avg_response = {}
|
||||||
for dtype in ["medical", "non_medical", "nursing", "support_services"]:
|
for dtype in ["medical", "administrative", "nursing", "support_services"]:
|
||||||
depts = categories.get(dtype, [])
|
depts = categories.get(dtype, [])
|
||||||
total_hours = sum(d["avg_response_days"] * 24 * (d["total"] - d.get("_excluded", 0)) for d in depts)
|
total_hours = sum(d["avg_response_days"] * 24 * (d["total"] - d.get("_excluded", 0)) for d in depts)
|
||||||
total_count = sum(d["total"] for d in depts)
|
total_count = sum(d["total"] for d in depts)
|
||||||
@ -526,8 +545,8 @@ class ComplaintQuarterlyService:
|
|||||||
domain_type = c.department.category
|
domain_type = c.department.category
|
||||||
elif c.domain:
|
elif c.domain:
|
||||||
dt = c.domain.domain_type or ""
|
dt = c.domain.domain_type or ""
|
||||||
if dt == "admin" or dt == "non_medical":
|
if dt in ("admin", "non_medical", "MANAGEMENT"):
|
||||||
domain_type = "non_medical"
|
domain_type = "administrative"
|
||||||
elif dt == "nursing":
|
elif dt == "nursing":
|
||||||
domain_type = "nursing"
|
domain_type = "nursing"
|
||||||
elif dt in ("support", "support_services"):
|
elif dt in ("support", "support_services"):
|
||||||
@ -578,8 +597,8 @@ class ComplaintQuarterlyService:
|
|||||||
source_label = "Patient's relatives"
|
source_label = "Patient's relatives"
|
||||||
|
|
||||||
location_label = ""
|
location_label = ""
|
||||||
if c.location and c.location.name_en:
|
if c.department and c.department.location_type:
|
||||||
location_label = c.location.name_en
|
location_label = c.department.location_type
|
||||||
|
|
||||||
dept_label = c.department.name if c.department else ""
|
dept_label = c.department.name if c.department else ""
|
||||||
domain_label = c.domain.name_en if c.domain else ""
|
domain_label = c.domain.name_en if c.domain else ""
|
||||||
@ -768,16 +787,18 @@ class ComplaintQuarterlyService:
|
|||||||
result.append({
|
result.append({
|
||||||
"month": calendar.month_abbr[m],
|
"month": calendar.month_abbr[m],
|
||||||
"medical": d["medical"],
|
"medical": d["medical"],
|
||||||
"admin": d["admin"],
|
"administrative": d["administrative"],
|
||||||
"nursing": d["nursing"],
|
"nursing": d["nursing"],
|
||||||
"support": d["support"],
|
"support": d["support"],
|
||||||
|
"other": d["other"],
|
||||||
"total": d["total"],
|
"total": d["total"],
|
||||||
})
|
})
|
||||||
totals = {
|
totals = {
|
||||||
"medical": sum(r["medical"] for r in result),
|
"medical": sum(r["medical"] for r in result),
|
||||||
"admin": sum(r["admin"] for r in result),
|
"administrative": sum(r["administrative"] for r in result),
|
||||||
"nursing": sum(r["nursing"] for r in result),
|
"nursing": sum(r["nursing"] for r in result),
|
||||||
"support": sum(r["support"] for r in result),
|
"support": sum(r["support"] for r in result),
|
||||||
|
"other": sum(r["other"] for r in result),
|
||||||
"total": sum(r["total"] for r in result),
|
"total": sum(r["total"] for r in result),
|
||||||
}
|
}
|
||||||
total_all = totals["total"]
|
total_all = totals["total"]
|
||||||
@ -810,6 +831,7 @@ class ComplaintQuarterlyService:
|
|||||||
"source_distribution": {
|
"source_distribution": {
|
||||||
"external": sum(s["external"] for s in source.values()),
|
"external": sum(s["external"] for s in source.values()),
|
||||||
"internal": sum(s["internal"] for s in source.values()),
|
"internal": sum(s["internal"] for s in source.values()),
|
||||||
|
"by_source": source[self.active_months[0]]["by_source"] if self.active_months else [],
|
||||||
},
|
},
|
||||||
"location_distribution": {
|
"location_distribution": {
|
||||||
"IP": sum(l["ip"] for l in location.values()),
|
"IP": sum(l["ip"] for l in location.values()),
|
||||||
@ -818,9 +840,10 @@ class ComplaintQuarterlyService:
|
|||||||
},
|
},
|
||||||
"dept_type_distribution": {
|
"dept_type_distribution": {
|
||||||
"Medical": sum(d["medical"] for d in dept_type.values()),
|
"Medical": sum(d["medical"] for d in dept_type.values()),
|
||||||
"Admin": sum(d["admin"] for d in dept_type.values()),
|
"Administrative": sum(d["administrative"] for d in dept_type.values()),
|
||||||
"Nursing": sum(d["nursing"] for d in dept_type.values()),
|
"Nursing": sum(d["nursing"] for d in dept_type.values()),
|
||||||
"Support": sum(d["support"] for d in dept_type.values()),
|
"Support Services": sum(d["support"] for d in dept_type.values()),
|
||||||
|
"Other": sum(d["other"] for d in dept_type.values()),
|
||||||
},
|
},
|
||||||
"satisfaction": {
|
"satisfaction": {
|
||||||
"months": self.active_month_labels,
|
"months": self.active_month_labels,
|
||||||
|
|||||||
@ -112,8 +112,8 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
Q(department=user.department) | Q(outgoing_department=user.department)
|
Q(department=user.department) | Q(outgoing_department=user.department)
|
||||||
)
|
)
|
||||||
actions_qs = PXAction.objects.filter(department=user.department)
|
actions_qs = PXAction.objects.filter(department=user.department)
|
||||||
surveys_qs = SurveyInstance.objects.none()
|
surveys_qs = SurveyInstance.objects.filter(journey_instance__department=user.department)
|
||||||
calls_qs = CallCenterInteraction.objects.none()
|
calls_qs = CallCenterInteraction.objects.filter(department=user.department)
|
||||||
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
||||||
elif user.is_director():
|
elif user.is_director():
|
||||||
directed_depts = user.get_directed_departments()
|
directed_depts = user.get_directed_departments()
|
||||||
@ -131,6 +131,21 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
|
|||||||
surveys_qs = SurveyInstance.objects.none()
|
surveys_qs = SurveyInstance.objects.none()
|
||||||
calls_qs = CallCenterInteraction.objects.none()
|
calls_qs = CallCenterInteraction.objects.none()
|
||||||
observations_qs = Observation.objects.none()
|
observations_qs = Observation.objects.none()
|
||||||
|
elif user.is_executive():
|
||||||
|
if 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(journey_instance__department__hospital=user.hospital)
|
||||||
|
calls_qs = CallCenterInteraction.objects.filter(department__hospital=user.hospital)
|
||||||
|
observations_qs = Observation.objects.filter(hospital=user.hospital)
|
||||||
|
else:
|
||||||
|
complaints_qs = Complaint.objects.none()
|
||||||
|
inquiries_qs = Inquiry.objects.none()
|
||||||
|
actions_qs = PXAction.objects.none()
|
||||||
|
surveys_qs = SurveyInstance.objects.none()
|
||||||
|
calls_qs = CallCenterInteraction.objects.none()
|
||||||
|
observations_qs = Observation.objects.none()
|
||||||
else:
|
else:
|
||||||
complaints_qs = Complaint.objects.none()
|
complaints_qs = Complaint.objects.none()
|
||||||
inquiries_qs = Inquiry.objects.none()
|
inquiries_qs = Inquiry.objects.none()
|
||||||
@ -1379,6 +1394,14 @@ def command_center_api(request):
|
|||||||
actions_qs = PXAction.objects.filter(department=user.department)
|
actions_qs = PXAction.objects.filter(department=user.department)
|
||||||
surveys_qs = SurveyInstance.objects.filter(journey_instance__department=user.department)
|
surveys_qs = SurveyInstance.objects.filter(journey_instance__department=user.department)
|
||||||
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
||||||
|
elif user.is_champion() and user.department:
|
||||||
|
complaints_qs = Complaint.objects.filter(department=user.department)
|
||||||
|
inquiries_qs = Inquiry.objects.filter(
|
||||||
|
Q(department=user.department) | Q(outgoing_department=user.department)
|
||||||
|
)
|
||||||
|
actions_qs = PXAction.objects.filter(department=user.department)
|
||||||
|
surveys_qs = SurveyInstance.objects.filter(journey_instance__department=user.department)
|
||||||
|
observations_qs = Observation.objects.filter(assigned_department=user.department)
|
||||||
elif user.is_director():
|
elif user.is_director():
|
||||||
directed_depts = user.get_directed_departments()
|
directed_depts = user.get_directed_departments()
|
||||||
if directed_depts.exists():
|
if directed_depts.exists():
|
||||||
@ -1393,6 +1416,19 @@ def command_center_api(request):
|
|||||||
actions_qs = PXAction.objects.none()
|
actions_qs = PXAction.objects.none()
|
||||||
surveys_qs = SurveyInstance.objects.none()
|
surveys_qs = SurveyInstance.objects.none()
|
||||||
observations_qs = Observation.objects.none()
|
observations_qs = Observation.objects.none()
|
||||||
|
elif user.is_executive():
|
||||||
|
if 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)
|
||||||
|
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()
|
||||||
else:
|
else:
|
||||||
complaints_qs = Complaint.objects.none()
|
complaints_qs = Complaint.objects.none()
|
||||||
inquiries_qs = Inquiry.objects.none()
|
inquiries_qs = Inquiry.objects.none()
|
||||||
@ -2516,7 +2552,7 @@ def complaint_quarterly_report(request):
|
|||||||
|
|
||||||
context["kpi_blocks"] = _build_kpi_blocks(kpi_data, satisfaction_data, moh_kpi_data, active_months)
|
context["kpi_blocks"] = _build_kpi_blocks(kpi_data, satisfaction_data, moh_kpi_data, active_months)
|
||||||
|
|
||||||
context["source_sub_external"] = _build_source_subrows(source_breakdown, ["moh", "chi", "insurance"], ["MOH", "CHI", "Insurance Company"], active_months)
|
context["source_sub_external"] = _build_source_subrows(source_breakdown, ["moh", "chi"], ["MOH", "CHI"], active_months)
|
||||||
context["source_sub_internal"] = _build_source_subrows(source_breakdown, ["patients", "relatives"], ["Patients", "Patient's relatives"], active_months)
|
context["source_sub_internal"] = _build_source_subrows(source_breakdown, ["patients", "relatives"], ["Patients", "Patient's relatives"], active_months)
|
||||||
context["source_internal_monthly"] = [source_breakdown[m]["internal"] for m in active_months]
|
context["source_internal_monthly"] = [source_breakdown[m]["internal"] for m in active_months]
|
||||||
context["source_total_monthly"] = [source_breakdown[m]["total"] for m in active_months]
|
context["source_total_monthly"] = [source_breakdown[m]["total"] for m in active_months]
|
||||||
@ -2525,12 +2561,6 @@ def complaint_quarterly_report(request):
|
|||||||
{"label": "Internal Complaints", "count": source_totals["internal"]["count"], "pct_label": "% Internal", "pct": source_totals["internal"]["pct"]},
|
{"label": "Internal Complaints", "count": source_totals["internal"]["count"], "pct_label": "% Internal", "pct": source_totals["internal"]["pct"]},
|
||||||
{"label": "External Complaints", "count": source_totals["external"]["count"], "pct_label": "% External", "pct": source_totals["external"]["pct"]},
|
{"label": "External Complaints", "count": source_totals["external"]["count"], "pct_label": "% External", "pct": source_totals["external"]["pct"]},
|
||||||
]
|
]
|
||||||
if source_totals["moh"]["count"] > 0:
|
|
||||||
context["source_total_rows"].append({"label": "MOH", "count": source_totals["moh"]["count"], "pct_label": "% MOH", "pct": source_totals["moh"]["pct"]})
|
|
||||||
if source_totals["chi"]["count"] > 0:
|
|
||||||
context["source_total_rows"].append({"label": "CHI", "count": source_totals["chi"]["count"], "pct_label": "% CHI", "pct": source_totals["chi"]["pct"]})
|
|
||||||
if source_totals["insurance"]["count"] > 0:
|
|
||||||
context["source_total_rows"].append({"label": "Insurance Company", "count": source_totals["insurance"]["count"], "pct_label": "% Insurance Comp.", "pct": source_totals["insurance"]["pct"]})
|
|
||||||
|
|
||||||
total_all = sum(location_breakdown[m]["total"] for m in active_months)
|
total_all = sum(location_breakdown[m]["total"] for m in active_months)
|
||||||
context["location_rows"] = []
|
context["location_rows"] = []
|
||||||
@ -2546,7 +2576,7 @@ def complaint_quarterly_report(request):
|
|||||||
})
|
})
|
||||||
|
|
||||||
context["dept_type_rows"] = []
|
context["dept_type_rows"] = []
|
||||||
for label, key in [("Medical", "medical"), ("Admin", "admin"), ("Nursing", "nursing"), ("Support Services", "support")]:
|
for label, key in [("Medical", "medical"), ("Administrative", "administrative"), ("Nursing", "nursing"), ("Support Services", "support")]:
|
||||||
t = sum(dept_type_breakdown[m][key] for m in active_months)
|
t = sum(dept_type_breakdown[m][key] for m in active_months)
|
||||||
context["dept_type_rows"].append({
|
context["dept_type_rows"].append({
|
||||||
"label": label, "total": t, "pct": round(t / total_all, 3) if total_all else 0,
|
"label": label, "total": t, "pct": round(t / total_all, 3) if total_all else 0,
|
||||||
@ -2560,22 +2590,24 @@ def complaint_quarterly_report(request):
|
|||||||
context["dept_monthly_rows"] = []
|
context["dept_monthly_rows"] = []
|
||||||
for mr in dept_type_monthly["months"]:
|
for mr in dept_type_monthly["months"]:
|
||||||
context["dept_monthly_rows"].append({
|
context["dept_monthly_rows"].append({
|
||||||
"month": mr["month"], "medical": mr["medical"], "admin": mr["admin"],
|
"month": mr["month"], "medical": mr["medical"], "administrative": mr["administrative"],
|
||||||
"nursing": mr["nursing"], "support": mr["support"], "total": mr["total"],
|
"nursing": mr["nursing"], "support": mr["support"], "total": mr["total"],
|
||||||
"is_total": False, "is_pct": False,
|
"is_total": False, "is_pct": False,
|
||||||
})
|
})
|
||||||
context["dept_monthly_rows"].append({
|
context["dept_monthly_rows"].append({
|
||||||
"month": "TOTAL",
|
"month": "TOTAL",
|
||||||
"medical": dept_type_monthly["totals"]["medical"], "admin": dept_type_monthly["totals"]["admin"],
|
"medical": dept_type_monthly["totals"]["medical"], "administrative": dept_type_monthly["totals"]["administrative"],
|
||||||
"nursing": dept_type_monthly["totals"]["nursing"], "support": dept_type_monthly["totals"]["support"],
|
"nursing": dept_type_monthly["totals"]["nursing"], "support": dept_type_monthly["totals"]["support"],
|
||||||
"total": dept_type_monthly["totals"]["total"],
|
"total": dept_type_monthly["totals"]["total"],
|
||||||
"is_total": True, "is_pct": False,
|
"is_total": True, "is_pct": False,
|
||||||
})
|
})
|
||||||
context["dept_monthly_rows"].append({
|
context["dept_monthly_rows"].append({
|
||||||
"month": "% From total",
|
"month": "% From Total",
|
||||||
"medical": dept_type_monthly["percentages"]["medical"], "admin": dept_type_monthly["percentages"]["admin"],
|
"medical": round(dept_type_monthly["percentages"]["medical"] * 100, 1),
|
||||||
"nursing": dept_type_monthly["percentages"]["nursing"], "support": dept_type_monthly["percentages"]["support"],
|
"administrative": round(dept_type_monthly["percentages"]["administrative"] * 100, 1),
|
||||||
"total": dept_type_monthly["percentages"]["total"],
|
"nursing": round(dept_type_monthly["percentages"]["nursing"] * 100, 1),
|
||||||
|
"support": round(dept_type_monthly["percentages"]["support"] * 100, 1),
|
||||||
|
"total": round(dept_type_monthly["percentages"]["total"] * 100, 1),
|
||||||
"is_total": False, "is_pct": True,
|
"is_total": False, "is_pct": True,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -2587,7 +2619,7 @@ def complaint_quarterly_report(request):
|
|||||||
per_dept = service.get_per_department_breakdown()
|
per_dept = service.get_per_department_breakdown()
|
||||||
cat_configs = [
|
cat_configs = [
|
||||||
("Medical", "medical"),
|
("Medical", "medical"),
|
||||||
("Non-Medical", "non_medical"),
|
("Administrative", "administrative"),
|
||||||
("Nursing", "nursing"),
|
("Nursing", "nursing"),
|
||||||
("Support Services", "support_services"),
|
("Support Services", "support_services"),
|
||||||
]
|
]
|
||||||
@ -2628,7 +2660,6 @@ def complaint_quarterly_report(request):
|
|||||||
totals = {
|
totals = {
|
||||||
"moh": sum(d["moh"] for d in depts),
|
"moh": sum(d["moh"] for d in depts),
|
||||||
"chi": sum(d["chi"] for d in depts),
|
"chi": sum(d["chi"] for d in depts),
|
||||||
"insurance": sum(d["insurance"] for d in depts),
|
|
||||||
"internal": sum(d["internal"] for d in depts),
|
"internal": sum(d["internal"] for d in depts),
|
||||||
"total": sum(d["total"] for d in depts),
|
"total": sum(d["total"] for d in depts),
|
||||||
"escalated": sum(d["escalated"] for d in depts),
|
"escalated": sum(d["escalated"] for d in depts),
|
||||||
@ -2641,7 +2672,6 @@ def complaint_quarterly_report(request):
|
|||||||
{"label": "MOH", "count": st["moh"], "is_total": False},
|
{"label": "MOH", "count": st["moh"], "is_total": False},
|
||||||
{"label": "CHI", "count": st["chi"], "is_total": False},
|
{"label": "CHI", "count": st["chi"], "is_total": False},
|
||||||
{"label": "Internal", "count": st["internal"], "is_total": False},
|
{"label": "Internal", "count": st["internal"], "is_total": False},
|
||||||
{"label": "Insurance Co.", "count": st["insurance"], "is_total": False},
|
|
||||||
{"label": "Total", "count": st["total"], "is_total": True},
|
{"label": "Total", "count": st["total"], "is_total": True},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -65,7 +65,22 @@ class FeedbackAdmin(admin.ModelAdmin):
|
|||||||
{"fields": ("id", "feedback_type", "title", "message", "category", "subcategory", "rating", "priority")},
|
{"fields": ("id", "feedback_type", "title", "message", "category", "subcategory", "rating", "priority")},
|
||||||
),
|
),
|
||||||
("Patient/Contact", {"fields": ("patient", "is_anonymous", "contact_name", "contact_email", "contact_phone")}),
|
("Patient/Contact", {"fields": ("patient", "is_anonymous", "contact_name", "contact_email", "contact_phone")}),
|
||||||
("Organization", {"fields": ("hospital", "department", "physician", "encounter_id")}),
|
(
|
||||||
|
"Organization",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"hospital",
|
||||||
|
"department",
|
||||||
|
"legacy_location",
|
||||||
|
"legacy_main_section",
|
||||||
|
"legacy_subsection",
|
||||||
|
"section",
|
||||||
|
|
||||||
|
"staff",
|
||||||
|
"encounter_id",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Status & Workflow",
|
"Status & Workflow",
|
||||||
{
|
{
|
||||||
|
|||||||
@ -314,12 +314,12 @@ class PublicSuggestionForm(forms.Form):
|
|||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
from apps.organizations.models import Hospital, Location, MainSection, SubSection
|
from apps.organizations.models import Hospital, LegacyLocation, LegacyMainSection, LegacySubSection
|
||||||
|
|
||||||
self.fields["hospital"].queryset = Hospital.objects.filter(status="active").order_by("name")
|
self.fields["hospital"].queryset = Hospital.objects.filter(status="active").order_by("name")
|
||||||
self.fields["location"].queryset = Location.active_locations()
|
self.fields["location"].queryset = LegacyLocation.active_locations()
|
||||||
self.fields["main_section"].queryset = MainSection.objects.none()
|
self.fields["main_section"].queryset = LegacyMainSection.objects.none()
|
||||||
self.fields["subsection"].queryset = SubSection.objects.none()
|
self.fields["subsection"].queryset = LegacySubSection.objects.none()
|
||||||
|
|
||||||
location_id = None
|
location_id = None
|
||||||
if "location" in self.initial:
|
if "location" in self.initial:
|
||||||
@ -329,9 +329,9 @@ class PublicSuggestionForm(forms.Form):
|
|||||||
|
|
||||||
if location_id:
|
if location_id:
|
||||||
available_sections = (
|
available_sections = (
|
||||||
SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
|
LegacySubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct()
|
||||||
)
|
)
|
||||||
self.fields["main_section"].queryset = MainSection.objects.filter(id__in=available_sections).order_by("name_en")
|
self.fields["main_section"].queryset = LegacyMainSection.objects.filter(id__in=available_sections).order_by("name_en")
|
||||||
|
|
||||||
section_id = None
|
section_id = None
|
||||||
if "main_section" in self.initial:
|
if "main_section" in self.initial:
|
||||||
@ -340,6 +340,6 @@ class PublicSuggestionForm(forms.Form):
|
|||||||
section_id = self.data["main_section"]
|
section_id = self.data["main_section"]
|
||||||
|
|
||||||
if section_id:
|
if section_id:
|
||||||
self.fields["subsection"].queryset = SubSection.objects.filter(
|
self.fields["subsection"].queryset = LegacySubSection.objects.filter(
|
||||||
location_id=location_id, main_section_id=section_id
|
location_id=location_id, main_section_id=section_id
|
||||||
).order_by("name_en")
|
).order_by("name_en")
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0004_legacylocation_legacymainsection_and_more'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -168,8 +168,8 @@ class Migration(migrations.Migration):
|
|||||||
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
('deleted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_%(class)s_set', to=settings.AUTH_USER_MODEL)),
|
||||||
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
|
('department', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.department')),
|
||||||
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')),
|
('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.hospital')),
|
||||||
('location', models.ForeignKey(blank=True, help_text='Location context', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.location')),
|
('location', models.ForeignKey(blank=True, help_text='Location context', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacylocation')),
|
||||||
('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.mainsection')),
|
('main_section', models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacymainsection')),
|
||||||
('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')),
|
('patient', models.ForeignKey(blank=True, help_text='Patient who provided feedback (optional for anonymous feedback)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='organizations.patient')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('feedback', '0002_initial'),
|
('feedback', '0002_initial'),
|
||||||
('organizations', '0001_initial'),
|
('organizations', '0004_legacylocation_legacymainsection_and_more'),
|
||||||
('px_sources', '0001_initial'),
|
('px_sources', '0001_initial'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='feedback',
|
model_name='feedback',
|
||||||
name='subsection',
|
name='subsection',
|
||||||
field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.subsection'),
|
field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacysubsection'),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='feedbackattachment',
|
model_name='feedbackattachment',
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:18
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feedback', '0003_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='feedback',
|
||||||
|
old_name='location',
|
||||||
|
new_name='legacy_location',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='feedback',
|
||||||
|
old_name='main_section',
|
||||||
|
new_name='legacy_main_section',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='feedback',
|
||||||
|
old_name='subsection',
|
||||||
|
new_name='legacy_subsection',
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feedback', '0004_remove_feedback_location_and_more'),
|
||||||
|
('organizations', '0008_rename_orgsubsection_to_section_add_champion'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks_new', to='organizations.Section'),
|
||||||
|
),
|
||||||
|
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('submitted', 'Submitted'), ('reviewed', 'Reviewed'), ('acknowledged', 'Acknowledged'), ('closed', 'Closed'), ('reopened', 'Reopened')], db_index=True, default='submitted', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-05-28 19:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feedback', '0005_feedback_legacy_location_and_more'),
|
||||||
|
('organizations', '0005_alter_legacylocation_table_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Location context', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Main section within the location', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
10
apps/feedback/migrations/0007_remove_sub_subsection.py
Normal file
10
apps/feedback/migrations/0007_remove_sub_subsection.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feedback', '0006_alter_feedback_legacy_location_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-06-14 10:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('feedback', '0007_remove_sub_subsection'),
|
||||||
|
('organizations', '0014_remove_department_manager_1st'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='reference_number',
|
||||||
|
field=models.CharField(blank=True, db_index=True, max_length=40, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_location',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacylocation'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_main_section',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacymainsection'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='feedback',
|
||||||
|
name='legacy_subsection',
|
||||||
|
field=models.ForeignKey(blank=True, help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedbacks', to='organizations.legacysubsection'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -9,6 +9,7 @@ This module implements the feedback management system that:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -33,6 +34,16 @@ class FeedbackStatus(models.TextChoices):
|
|||||||
REVIEWED = "reviewed", _("Reviewed")
|
REVIEWED = "reviewed", _("Reviewed")
|
||||||
ACKNOWLEDGED = "acknowledged", _("Acknowledged")
|
ACKNOWLEDGED = "acknowledged", _("Acknowledged")
|
||||||
CLOSED = "closed", _("Closed")
|
CLOSED = "closed", _("Closed")
|
||||||
|
REOPENED = "reopened", _("Reopened")
|
||||||
|
|
||||||
|
|
||||||
|
VALID_FEEDBACK_TRANSITIONS = {
|
||||||
|
FeedbackStatus.SUBMITTED: [FeedbackStatus.REVIEWED, FeedbackStatus.CLOSED],
|
||||||
|
FeedbackStatus.REVIEWED: [FeedbackStatus.ACKNOWLEDGED, FeedbackStatus.CLOSED],
|
||||||
|
FeedbackStatus.ACKNOWLEDGED: [FeedbackStatus.CLOSED],
|
||||||
|
FeedbackStatus.CLOSED: [FeedbackStatus.REOPENED],
|
||||||
|
FeedbackStatus.REOPENED: [FeedbackStatus.REVIEWED, FeedbackStatus.ACKNOWLEDGED, FeedbackStatus.CLOSED],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class FeedbackCategory(models.TextChoices):
|
class FeedbackCategory(models.TextChoices):
|
||||||
@ -101,29 +112,38 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
|
|
||||||
# Organization
|
# Organization
|
||||||
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="feedbacks")
|
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="feedbacks")
|
||||||
location = models.ForeignKey(
|
legacy_location = models.ForeignKey(
|
||||||
"organizations.Location",
|
"organizations.LegacyLocation",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="feedbacks",
|
related_name="feedbacks",
|
||||||
help_text="Location context",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
main_section = models.ForeignKey(
|
legacy_main_section = models.ForeignKey(
|
||||||
"organizations.MainSection",
|
"organizations.LegacyMainSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="feedbacks",
|
related_name="feedbacks",
|
||||||
help_text="Main section within the location",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
)
|
)
|
||||||
subsection = models.ForeignKey(
|
legacy_subsection = models.ForeignKey(
|
||||||
"organizations.SubSection",
|
"organizations.LegacySubSection",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="feedbacks",
|
related_name="feedbacks",
|
||||||
help_text="Specific subsection",
|
help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.",
|
||||||
|
)
|
||||||
|
# New hierarchy (from 4th Version Excel)
|
||||||
|
section = models.ForeignKey(
|
||||||
|
"organizations.Section",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="feedbacks_new",
|
||||||
|
help_text="Section within department",
|
||||||
)
|
)
|
||||||
department = models.ForeignKey(
|
department = models.ForeignKey(
|
||||||
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedbacks"
|
"organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="feedbacks"
|
||||||
@ -145,6 +165,9 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
message = models.TextField(help_text="Feedback message")
|
message = models.TextField(help_text="Feedback message")
|
||||||
|
|
||||||
|
# Reference number (unified format SGT-YYYYMM-HOSP-NNNN; internal-only, not publicly trackable)
|
||||||
|
reference_number = models.CharField(max_length=40, unique=True, blank=True, null=True, db_index=True)
|
||||||
|
|
||||||
# Classification
|
# Classification
|
||||||
category = models.CharField(max_length=50, choices=FeedbackCategory.choices, db_index=True)
|
category = models.CharField(max_length=50, choices=FeedbackCategory.choices, db_index=True)
|
||||||
subcategory = models.CharField(max_length=100, blank=True)
|
subcategory = models.CharField(max_length=100, blank=True)
|
||||||
@ -216,6 +239,8 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
# Metadata
|
# Metadata
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
notes = GenericRelation("core.Note")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
@ -233,6 +258,13 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
|||||||
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
|
return f"{self.title} - {self.patient.get_full_name()} ({self.feedback_type})"
|
||||||
return f"{self.title} - Anonymous ({self.feedback_type})"
|
return f"{self.title} - Anonymous ({self.feedback_type})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.reference_number:
|
||||||
|
from apps.core.reference import generate_reference
|
||||||
|
|
||||||
|
self.reference_number = generate_reference("SGT", self.hospital)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.accounts.services import StaffActivityService
|
from apps.accounts.services import StaffActivityService
|
||||||
@ -24,6 +25,7 @@ from .models import (
|
|||||||
FeedbackStatus,
|
FeedbackStatus,
|
||||||
FeedbackType,
|
FeedbackType,
|
||||||
FeedbackCategory,
|
FeedbackCategory,
|
||||||
|
VALID_FEEDBACK_TRANSITIONS,
|
||||||
)
|
)
|
||||||
from .forms import (
|
from .forms import (
|
||||||
FeedbackForm,
|
FeedbackForm,
|
||||||
@ -141,12 +143,13 @@ def feedback_list(request):
|
|||||||
if date_to:
|
if date_to:
|
||||||
queryset = queryset.filter(created_at__lte=date_to)
|
queryset = queryset.filter(created_at__lte=date_to)
|
||||||
|
|
||||||
# Ordering
|
ALLOWED_ORDER_BY = {"-created_at", "created_at", "-updated_at", "updated_at", "-title", "title", "-rating", "rating"}
|
||||||
order_by = request.GET.get("order_by", "-created_at")
|
order_by = request.GET.get("order_by", "-created_at")
|
||||||
|
if order_by not in ALLOWED_ORDER_BY:
|
||||||
|
order_by = "-created_at"
|
||||||
queryset = queryset.order_by(order_by)
|
queryset = queryset.order_by(order_by)
|
||||||
|
|
||||||
# Pagination
|
page_size = min(int(request.GET.get("page_size", 25)), 100)
|
||||||
page_size = int(request.GET.get("page_size", 25))
|
|
||||||
paginator = Paginator(queryset, page_size)
|
paginator = Paginator(queryset, page_size)
|
||||||
page_number = request.GET.get("page", 1)
|
page_number = request.GET.get("page", 1)
|
||||||
page_obj = paginator.get_page(page_number)
|
page_obj = paginator.get_page(page_number)
|
||||||
@ -238,6 +241,10 @@ def feedback_detail(request, pk):
|
|||||||
"assigned_to", "created_by"
|
"assigned_to", "created_by"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
feedback_ct = ContentType.objects.get_for_model(Feedback)
|
||||||
|
generic_notes = feedback.notes.select_related("created_by").all()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"feedback": feedback,
|
"feedback": feedback,
|
||||||
"timeline": timeline,
|
"timeline": timeline,
|
||||||
@ -246,6 +253,10 @@ def feedback_detail(request, pk):
|
|||||||
"status_choices": FeedbackStatus.choices,
|
"status_choices": FeedbackStatus.choices,
|
||||||
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
|
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
|
||||||
"linked_rcas": linked_rcas,
|
"linked_rcas": linked_rcas,
|
||||||
|
"content_type_id": feedback_ct.pk,
|
||||||
|
"object_id": feedback.pk,
|
||||||
|
"notes": generic_notes,
|
||||||
|
"notes_count": generic_notes.count(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, "feedback/feedback_detail.html", context)
|
return render(request, "feedback/feedback_detail.html", context)
|
||||||
@ -254,7 +265,7 @@ def feedback_detail(request, pk):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET", "POST"])
|
@require_http_methods(["GET", "POST"])
|
||||||
def feedback_create(request):
|
def feedback_create(request):
|
||||||
from apps.organizations.models import Location, MainSection, SubSection, Hospital
|
from apps.organizations.models import LegacyLocation, LegacyMainSection, LegacySubSection, Hospital
|
||||||
from apps.feedback.models import FeedbackType, FeedbackCategory, FeedbackStatus
|
from apps.feedback.models import FeedbackType, FeedbackCategory, FeedbackStatus
|
||||||
|
|
||||||
communication_request = None
|
communication_request = None
|
||||||
@ -275,9 +286,6 @@ def feedback_create(request):
|
|||||||
message = request.POST.get("message", "").strip()
|
message = request.POST.get("message", "").strip()
|
||||||
hospital_id = request.POST.get("hospital", "")
|
hospital_id = request.POST.get("hospital", "")
|
||||||
title = request.POST.get("title", message[:100]).strip()
|
title = request.POST.get("title", message[:100]).strip()
|
||||||
location_id = request.POST.get("location", "").strip()
|
|
||||||
main_section_id = request.POST.get("main_section", "").strip()
|
|
||||||
subsection_id = request.POST.get("subsection", "").strip()
|
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
if not contact_name:
|
if not contact_name:
|
||||||
@ -295,9 +303,6 @@ def feedback_create(request):
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
hospital = Hospital.objects.get(id=hospital_id)
|
hospital = Hospital.objects.get(id=hospital_id)
|
||||||
location = Location.objects.filter(id=location_id).first() if location_id else None
|
|
||||||
main_section = MainSection.objects.filter(id=main_section_id).first() if main_section_id else None
|
|
||||||
subsection = SubSection.objects.filter(id=subsection_id).first() if subsection_id else None
|
|
||||||
|
|
||||||
feedback = Feedback(
|
feedback = Feedback(
|
||||||
hospital=hospital,
|
hospital=hospital,
|
||||||
@ -309,9 +314,6 @@ def feedback_create(request):
|
|||||||
contact_phone=contact_phone,
|
contact_phone=contact_phone,
|
||||||
is_anonymous=False,
|
is_anonymous=False,
|
||||||
status=FeedbackStatus.SUBMITTED,
|
status=FeedbackStatus.SUBMITTED,
|
||||||
location=location,
|
|
||||||
main_section=main_section,
|
|
||||||
subsection=subsection,
|
|
||||||
)
|
)
|
||||||
feedback.save()
|
feedback.save()
|
||||||
|
|
||||||
@ -681,12 +683,6 @@ def export_action_plans(request):
|
|||||||
|
|
||||||
return export_action_plans(qs)
|
return export_action_plans(qs)
|
||||||
|
|
||||||
context = {
|
|
||||||
"feedback": feedback,
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, "feedback/feedback_delete_confirm.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["POST"])
|
@require_http_methods(["POST"])
|
||||||
@ -757,10 +753,27 @@ def feedback_change_status(request, pk):
|
|||||||
messages.error(request, "Please select a status.")
|
messages.error(request, "Please select a status.")
|
||||||
return redirect("feedback:feedback_detail", pk=pk)
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
|
valid_status_values = [s[0] for s in FeedbackStatus.choices]
|
||||||
|
if new_status not in valid_status_values:
|
||||||
|
messages.error(request, "Invalid status value.")
|
||||||
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
old_status = feedback.status
|
old_status = feedback.status
|
||||||
|
|
||||||
|
if old_status == new_status:
|
||||||
|
messages.info(request, "Suggestion is already in this status.")
|
||||||
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
|
allowed = VALID_FEEDBACK_TRANSITIONS.get(old_status, [])
|
||||||
|
if new_status not in allowed:
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f"Cannot change status from {old_status} to {new_status}. Allowed transitions: {', '.join(allowed)}.",
|
||||||
|
)
|
||||||
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
feedback.status = new_status
|
feedback.status = new_status
|
||||||
|
|
||||||
# Handle status-specific logic
|
|
||||||
if new_status == FeedbackStatus.REVIEWED:
|
if new_status == FeedbackStatus.REVIEWED:
|
||||||
feedback.reviewed_at = timezone.now()
|
feedback.reviewed_at = timezone.now()
|
||||||
feedback.reviewed_by = request.user
|
feedback.reviewed_by = request.user
|
||||||
@ -770,6 +783,9 @@ def feedback_change_status(request, pk):
|
|||||||
elif new_status == FeedbackStatus.CLOSED:
|
elif new_status == FeedbackStatus.CLOSED:
|
||||||
feedback.closed_at = timezone.now()
|
feedback.closed_at = timezone.now()
|
||||||
feedback.closed_by = request.user
|
feedback.closed_by = request.user
|
||||||
|
elif new_status == FeedbackStatus.REOPENED:
|
||||||
|
feedback.closed_at = None
|
||||||
|
feedback.closed_by = None
|
||||||
|
|
||||||
feedback.save()
|
feedback.save()
|
||||||
|
|
||||||
@ -805,6 +821,12 @@ def feedback_add_response(request, pk):
|
|||||||
"""Add response to feedback"""
|
"""Add response to feedback"""
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()
|
||||||
|
or user.is_department_manager()):
|
||||||
|
messages.error(request, "You don't have permission to add responses to this suggestion.")
|
||||||
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
response_type = request.POST.get("response_type", "response")
|
response_type = request.POST.get("response_type", "response")
|
||||||
message = request.POST.get("message")
|
message = request.POST.get("message")
|
||||||
is_internal = request.POST.get("is_internal") == "on"
|
is_internal = request.POST.get("is_internal") == "on"
|
||||||
@ -875,6 +897,12 @@ def public_suggestion_submit(request):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
client_ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR', '')
|
||||||
|
cache_key = f"suggestion_rate:{client_ip}"
|
||||||
|
if cache.get(cache_key, 0) >= 5:
|
||||||
|
return JsonResponse({"success": False, "message": "Too many requests. Please try again later."}, status=429)
|
||||||
|
cache.set(cache_key, cache.get(cache_key, 0) + 1, 300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
data = json.loads(request.body) if request.content_type == "application/json" else request.POST
|
data = json.loads(request.body) if request.content_type == "application/json" else request.POST
|
||||||
@ -902,11 +930,6 @@ def public_suggestion_submit(request):
|
|||||||
except Hospital.DoesNotExist:
|
except Hospital.DoesNotExist:
|
||||||
return JsonResponse({"success": False, "message": "Invalid hospital."}, status=400)
|
return JsonResponse({"success": False, "message": "Invalid hospital."}, status=400)
|
||||||
|
|
||||||
from apps.organizations.models import Location, MainSection, SubSection
|
|
||||||
location = Location.objects.filter(id=location_id).first() if location_id else None
|
|
||||||
main_section = MainSection.objects.filter(id=main_section_id).first() if main_section_id else None
|
|
||||||
subsection = SubSection.objects.filter(id=subsection_id).first() if subsection_id else None
|
|
||||||
|
|
||||||
category_map = {
|
category_map = {
|
||||||
"general": FeedbackCategory.OTHER,
|
"general": FeedbackCategory.OTHER,
|
||||||
"clinical_care": FeedbackCategory.CLINICAL_CARE,
|
"clinical_care": FeedbackCategory.CLINICAL_CARE,
|
||||||
@ -927,9 +950,6 @@ def public_suggestion_submit(request):
|
|||||||
category=category_map.get(category, FeedbackCategory.OTHER),
|
category=category_map.get(category, FeedbackCategory.OTHER),
|
||||||
contact_name=contact_name,
|
contact_name=contact_name,
|
||||||
contact_phone=contact_phone,
|
contact_phone=contact_phone,
|
||||||
location=location,
|
|
||||||
main_section=main_section,
|
|
||||||
subsection=subsection,
|
|
||||||
is_anonymous=False,
|
is_anonymous=False,
|
||||||
status=FeedbackStatus.SUBMITTED,
|
status=FeedbackStatus.SUBMITTED,
|
||||||
metadata={
|
metadata={
|
||||||
@ -968,6 +988,11 @@ def feedback_create_action(request, pk):
|
|||||||
|
|
||||||
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
if not (user.is_px_admin() or user.is_hospital_admin()):
|
||||||
|
messages.error(request, "You don't have permission to create actions from suggestions.")
|
||||||
|
return redirect("feedback:feedback_detail", pk=pk)
|
||||||
|
|
||||||
action_title = request.POST.get("action_title", "").strip()
|
action_title = request.POST.get("action_title", "").strip()
|
||||||
action_description = request.POST.get("action_description", "").strip()
|
action_description = request.POST.get("action_description", "").strip()
|
||||||
action_category = request.POST.get("action_category", "other")
|
action_category = request.POST.get("action_category", "other")
|
||||||
|
|||||||
@ -7,6 +7,7 @@ from django.utils.html import format_html, mark_safe
|
|||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
EventMapping,
|
EventMapping,
|
||||||
|
ExternalAPIKey,
|
||||||
HISEventType,
|
HISEventType,
|
||||||
HISTestPatient,
|
HISTestPatient,
|
||||||
HISTestVisit,
|
HISTestVisit,
|
||||||
@ -260,3 +261,71 @@ class HISTestVisitAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def has_add_permission(self, request):
|
def has_add_permission(self, request):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExternalAPIKey)
|
||||||
|
class ExternalAPIKeyAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin for External API Keys - shows the plaintext key ONLY on creation."""
|
||||||
|
|
||||||
|
list_display = ["name", "key_prefix", "hospital", "is_active", "allowed_entities_display", "last_used_at", "created_at"]
|
||||||
|
list_filter = ["is_active", "hospital"]
|
||||||
|
search_fields = ["name", "key_prefix", "description"]
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
readonly_fields = ["key_hash", "key_prefix", "last_used_at", "created_at", "updated_at"]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
"Identity",
|
||||||
|
{"fields": ("name", "description", "key_prefix", "key_hash")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Scope",
|
||||||
|
{"fields": ("hospital", "allowed_entities", "rate_limit")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Status",
|
||||||
|
{"fields": ("is_active", "expires_at", "last_used_at")},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Metadata",
|
||||||
|
{"fields": ("created_by", "created_at", "updated_at")},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def allowed_entities_display(self, obj):
|
||||||
|
if not obj.allowed_entities:
|
||||||
|
return mark_safe('<span class="badge bg-success">All Entities</span>')
|
||||||
|
return ", ".join(obj.allowed_entities)
|
||||||
|
|
||||||
|
allowed_entities_display.short_description = "Allowed Entities"
|
||||||
|
|
||||||
|
def save_model(self, request, obj, form, change):
|
||||||
|
if not change:
|
||||||
|
# Creating a new key — generate it
|
||||||
|
obj.created_by = request.user
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
# Store the raw key to show in admin message
|
||||||
|
# We need to generate the key ourselves since save_model doesn't return it
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
raw_key = secrets.token_hex(32)
|
||||||
|
obj.key_prefix = raw_key[:8]
|
||||||
|
obj.key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||||
|
obj.save(update_fields=["key_prefix", "key_hash"])
|
||||||
|
|
||||||
|
self._generated_key = raw_key
|
||||||
|
else:
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
def response_add(self, request, obj, post_url_continue=None):
|
||||||
|
resp = super().response_add(request, obj, post_url_continue)
|
||||||
|
if hasattr(self, "_generated_key"):
|
||||||
|
from django.contrib import messages
|
||||||
|
messages.success(
|
||||||
|
request,
|
||||||
|
f'API Key created. Copy it now — it will NOT be shown again: {self._generated_key}',
|
||||||
|
)
|
||||||
|
# Clear it
|
||||||
|
del self._generated_key
|
||||||
|
return resp
|
||||||
|
|||||||
529
apps/integrations/api_serializers.py
Normal file
529
apps/integrations/api_serializers.py
Normal file
@ -0,0 +1,529 @@
|
|||||||
|
"""
|
||||||
|
External API serializers for public-facing create/retrieve operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.complaints.models import Complaint, Inquiry
|
||||||
|
from apps.observations.models import Observation
|
||||||
|
from apps.appreciation.models import Appreciation, AppreciationStatus, AppreciationVisibility
|
||||||
|
from apps.feedback.models import Feedback, FeedbackType, FeedbackStatus, FeedbackCategory
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Complaint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalComplaintCreateSerializer(serializers.Serializer):
|
||||||
|
"""Public-facing fields for creating a complaint via external API."""
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
contact_name = serializers.CharField(max_length=200, required=True)
|
||||||
|
contact_phone = serializers.CharField(max_length=20, required=True)
|
||||||
|
contact_email = serializers.EmailField(required=False, allow_blank=True, default="")
|
||||||
|
relation_to_patient = serializers.ChoiceField(
|
||||||
|
choices=[("patient", "Patient"), ("relative", "Relative"), ("friend", "Friend"), ("other", "Other")],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patient
|
||||||
|
patient_name = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
national_id = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
|
||||||
|
incident_date = serializers.DateField(required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
# Hospital context
|
||||||
|
hospital = serializers.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
# Location hierarchy (all optional)
|
||||||
|
location_type = serializers.ChoiceField(
|
||||||
|
choices=[("OP", "Outpatient"), ("IP", "Inpatient"), ("ER", "Emergency"), ("GENERAL", "General")],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
area = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
department = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
section = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
# Complaint content
|
||||||
|
title = serializers.CharField(max_length=500, required=True)
|
||||||
|
description = serializers.CharField(required=True)
|
||||||
|
expected_result = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(name__iexact=value.strip())
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
hospital = data.get("hospital")
|
||||||
|
if hospital:
|
||||||
|
area_name = data.get("area")
|
||||||
|
if area_name:
|
||||||
|
from apps.organizations.models import Area
|
||||||
|
try:
|
||||||
|
data["area"] = Area.objects.get(
|
||||||
|
name_en__iexact=area_name.strip(), hospital=hospital, status="active"
|
||||||
|
)
|
||||||
|
except Area.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"area": f"Area '{area_name}' not found for this hospital."})
|
||||||
|
|
||||||
|
dept_name = data.get("department")
|
||||||
|
if dept_name:
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
try:
|
||||||
|
data["department"] = Department.objects.get(
|
||||||
|
name__iexact=dept_name.strip(), hospital=hospital, status="active"
|
||||||
|
)
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"department": f"Department '{dept_name}' not found for this hospital."}
|
||||||
|
)
|
||||||
|
|
||||||
|
section_name = data.get("section")
|
||||||
|
if section_name:
|
||||||
|
department = data.get("department")
|
||||||
|
if not department:
|
||||||
|
raise serializers.ValidationError({"section": "Department is required to look up section."})
|
||||||
|
from apps.organizations.models import Section
|
||||||
|
try:
|
||||||
|
data["section"] = Section.objects.get(
|
||||||
|
name_en__iexact=section_name.strip(), department=department, status="active"
|
||||||
|
)
|
||||||
|
except Section.DoesNotExist:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"section": f"Section '{section_name}' not found for this department."}
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalComplaintRetrieveSerializer(serializers.ModelSerializer):
|
||||||
|
"""Read-only serializer for retrieving a complaint."""
|
||||||
|
|
||||||
|
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Complaint
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"reference_number",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"status",
|
||||||
|
"severity",
|
||||||
|
"priority",
|
||||||
|
"contact_name",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_email",
|
||||||
|
"relation_to_patient",
|
||||||
|
"patient_name",
|
||||||
|
"incident_date",
|
||||||
|
"expected_result",
|
||||||
|
"resolution",
|
||||||
|
"satisfaction",
|
||||||
|
"hospital_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inquiry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalInquiryCreateSerializer(serializers.Serializer):
|
||||||
|
"""Public-facing fields for creating an inquiry via external API."""
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
contact_name = serializers.CharField(max_length=200, required=True)
|
||||||
|
contact_phone = serializers.CharField(max_length=20, required=True)
|
||||||
|
contact_email = serializers.EmailField(required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
# Hospital context
|
||||||
|
hospital = serializers.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
# Location hierarchy (all optional)
|
||||||
|
location_type = serializers.ChoiceField(
|
||||||
|
choices=[("OP", "Outpatient"), ("IP", "Inpatient"), ("ER", "Emergency"), ("GENERAL", "General")],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
area = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
department = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
section = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
# Inquiry content
|
||||||
|
subject = serializers.CharField(max_length=500, required=True)
|
||||||
|
message = serializers.CharField(required=True)
|
||||||
|
category = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("appointment", "Appointment"),
|
||||||
|
("billing", "Billing"),
|
||||||
|
("medical_records", "Medical Records"),
|
||||||
|
("general", "General Information"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
default="general",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(name__iexact=value.strip())
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
hospital = data.get("hospital")
|
||||||
|
if hospital:
|
||||||
|
area_name = data.get("area")
|
||||||
|
if area_name:
|
||||||
|
from apps.organizations.models import Area
|
||||||
|
try:
|
||||||
|
data["area"] = Area.objects.get(
|
||||||
|
name_en__iexact=area_name.strip(), hospital=hospital, status="active"
|
||||||
|
)
|
||||||
|
except Area.DoesNotExist:
|
||||||
|
raise serializers.ValidationError({"area": f"Area '{area_name}' not found for this hospital."})
|
||||||
|
|
||||||
|
dept_name = data.get("department")
|
||||||
|
if dept_name:
|
||||||
|
from apps.organizations.models import Department
|
||||||
|
try:
|
||||||
|
data["department"] = Department.objects.get(
|
||||||
|
name__iexact=dept_name.strip(), hospital=hospital, status="active"
|
||||||
|
)
|
||||||
|
except Department.DoesNotExist:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"department": f"Department '{dept_name}' not found for this hospital."}
|
||||||
|
)
|
||||||
|
|
||||||
|
section_name = data.get("section")
|
||||||
|
if section_name:
|
||||||
|
department = data.get("department")
|
||||||
|
if not department:
|
||||||
|
raise serializers.ValidationError({"section": "Department is required to look up section."})
|
||||||
|
from apps.organizations.models import Section
|
||||||
|
try:
|
||||||
|
data["section"] = Section.objects.get(
|
||||||
|
name_en__iexact=section_name.strip(), department=department, status="active"
|
||||||
|
)
|
||||||
|
except Section.DoesNotExist:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"section": f"Section '{section_name}' not found for this department."}
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalInquiryRetrieveSerializer(serializers.ModelSerializer):
|
||||||
|
"""Read-only serializer for retrieving an inquiry."""
|
||||||
|
|
||||||
|
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Inquiry
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"reference_number",
|
||||||
|
"subject",
|
||||||
|
"message",
|
||||||
|
"category",
|
||||||
|
"status",
|
||||||
|
"contact_name",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_email",
|
||||||
|
"hospital_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Observation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalObservationCreateSerializer(serializers.Serializer):
|
||||||
|
"""Public-facing fields for creating an observation via external API."""
|
||||||
|
|
||||||
|
# Hospital context
|
||||||
|
hospital = serializers.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
category = serializers.UUIDField(required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
title = serializers.CharField(max_length=300, required=False, allow_blank=True, default="")
|
||||||
|
description = serializers.CharField(required=True)
|
||||||
|
severity = serializers.ChoiceField(
|
||||||
|
choices=[("low", "Low"), ("medium", "Medium"), ("high", "High"), ("critical", "Critical")],
|
||||||
|
required=False,
|
||||||
|
default="medium",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Location and timing
|
||||||
|
location_text = serializers.CharField(max_length=500, required=False, allow_blank=True, default="")
|
||||||
|
incident_datetime = serializers.DateTimeField(required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
# Reporter info (all optional - anonymous supported)
|
||||||
|
contact_name = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
contact_phone = serializers.CharField(max_length=20, required=False, allow_blank=True, default="")
|
||||||
|
contact_email = serializers.EmailField(required=False, allow_blank=True, default="")
|
||||||
|
reporter_staff_id = serializers.CharField(max_length=50, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
# Patient info
|
||||||
|
patient_file_number = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(name__iexact=value.strip())
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
def validate_category(self, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
from apps.observations.models import ObservationCategory
|
||||||
|
try:
|
||||||
|
return ObservationCategory.objects.get(id=value)
|
||||||
|
except ObservationCategory.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Observation category not found.")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalObservationRetrieveSerializer(serializers.ModelSerializer):
|
||||||
|
"""Read-only serializer for retrieving an observation."""
|
||||||
|
|
||||||
|
reference_number = serializers.CharField(source="tracking_code", read_only=True)
|
||||||
|
hospital_name = serializers.CharField(source="hospital.name", read_only=True, default=None)
|
||||||
|
category_name = serializers.CharField(source="category.name", read_only=True, default=None)
|
||||||
|
contact_name = serializers.CharField(source="reporter_name", read_only=True)
|
||||||
|
contact_phone = serializers.CharField(source="reporter_phone", read_only=True)
|
||||||
|
contact_email = serializers.CharField(source="reporter_email", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Observation
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"reference_number",
|
||||||
|
"title",
|
||||||
|
"description",
|
||||||
|
"severity",
|
||||||
|
"status",
|
||||||
|
"location_text",
|
||||||
|
"incident_datetime",
|
||||||
|
"contact_name",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_email",
|
||||||
|
"reporter_staff_id",
|
||||||
|
"patient_file_number",
|
||||||
|
"hospital_name",
|
||||||
|
"category_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Appreciation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalAppreciationCreateSerializer(serializers.Serializer):
|
||||||
|
"""Public-facing fields for creating an appreciation via external API."""
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
contact_name = serializers.CharField(max_length=200, required=True)
|
||||||
|
contact_phone = serializers.CharField(max_length=20, required=True)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
message = serializers.CharField(required=True)
|
||||||
|
|
||||||
|
# Hospital context
|
||||||
|
hospital = serializers.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(name__iexact=value.strip())
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
|
||||||
|
"""Read-only serializer for retrieving an appreciation."""
|
||||||
|
|
||||||
|
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||||
|
reference_number = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Appreciation
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"reference_number",
|
||||||
|
"message_en",
|
||||||
|
"status",
|
||||||
|
"hospital_name",
|
||||||
|
"is_anonymous",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_reference_number(self, obj):
|
||||||
|
if obj.metadata and "reference_number" in obj.metadata:
|
||||||
|
return obj.metadata["reference_number"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Suggestion (Feedback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalSuggestionCreateSerializer(serializers.Serializer):
|
||||||
|
"""Public-facing fields for creating a suggestion via external API."""
|
||||||
|
|
||||||
|
# Contact
|
||||||
|
contact_name = serializers.CharField(max_length=200, required=True)
|
||||||
|
contact_phone = serializers.CharField(max_length=20, required=True)
|
||||||
|
|
||||||
|
# Content
|
||||||
|
title = serializers.CharField(max_length=500, required=False, allow_blank=True, default="")
|
||||||
|
message = serializers.CharField(required=True)
|
||||||
|
category = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("clinical_care", "Clinical Care"),
|
||||||
|
("staff_service", "Staff Service"),
|
||||||
|
("facility", "Facility"),
|
||||||
|
("communication", "Communication"),
|
||||||
|
("appointment", "Appointment"),
|
||||||
|
("billing", "Billing"),
|
||||||
|
("food_service", "Food Service"),
|
||||||
|
("cleanliness", "Cleanliness"),
|
||||||
|
("technology", "Technology"),
|
||||||
|
("general", "General"),
|
||||||
|
("other", "Other"),
|
||||||
|
],
|
||||||
|
required=False,
|
||||||
|
default="general",
|
||||||
|
)
|
||||||
|
rating = serializers.IntegerField(min_value=1, max_value=5, required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
# Hospital context
|
||||||
|
hospital = serializers.CharField(max_length=200, required=True)
|
||||||
|
|
||||||
|
def validate_hospital(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(name__iexact=value.strip())
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
|
||||||
|
"""Read-only serializer for retrieving a suggestion."""
|
||||||
|
|
||||||
|
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||||
|
reference_number = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Feedback
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"reference_number",
|
||||||
|
"title",
|
||||||
|
"message",
|
||||||
|
"category",
|
||||||
|
"rating",
|
||||||
|
"status",
|
||||||
|
"feedback_type",
|
||||||
|
"sentiment",
|
||||||
|
"contact_name",
|
||||||
|
"contact_phone",
|
||||||
|
"hospital_name",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
def get_reference_number(self, obj):
|
||||||
|
if obj.metadata and "reference_number" in obj.metadata:
|
||||||
|
return obj.metadata["reference_number"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalComplaintSatisfactionSerializer(serializers.Serializer):
|
||||||
|
satisfaction = serializers.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("satisfied", "Satisfied"),
|
||||||
|
("neutral", "Neutral"),
|
||||||
|
("dissatisfied", "Dissatisfied"),
|
||||||
|
("no_response", "No Response"),
|
||||||
|
],
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Doctor Rating
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExternalDoctorRatingCreateSerializer(serializers.Serializer):
|
||||||
|
|
||||||
|
hospital_id = serializers.IntegerField(required=True)
|
||||||
|
doctor_id = serializers.CharField(max_length=50, required=True)
|
||||||
|
doctor_name = serializers.CharField(max_length=300, required=False, allow_blank=True, default="")
|
||||||
|
rating = serializers.IntegerField(min_value=1, max_value=5, required=True)
|
||||||
|
feedback = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
rating_date = serializers.DateField(required=False, allow_null=True, default=None)
|
||||||
|
patient_uhid = serializers.CharField(max_length=100, required=False, allow_blank=True, default="")
|
||||||
|
patient_name = serializers.CharField(max_length=300, required=False, allow_blank=True, default="")
|
||||||
|
patient_type = serializers.ChoiceField(
|
||||||
|
choices=["IP", "OP", "ER", "DC"],
|
||||||
|
required=False,
|
||||||
|
allow_blank=True,
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
department_name = serializers.CharField(max_length=200, required=False, allow_blank=True, default="")
|
||||||
|
admit_date = serializers.DateField(required=False, allow_null=True, default=None)
|
||||||
|
discharge_date = serializers.DateField(required=False, allow_null=True, default=None)
|
||||||
|
|
||||||
|
def validate_hospital_id(self, value):
|
||||||
|
from apps.organizations.models import Hospital
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Hospital.objects.get(id=value)
|
||||||
|
except Hospital.DoesNotExist:
|
||||||
|
raise serializers.ValidationError("Hospital not found.")
|
||||||
|
|
||||||
|
def validate_doctor_id(self, value):
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
from apps.organizations.models import Staff
|
||||||
|
|
||||||
|
hospital = data.get("hospital_id")
|
||||||
|
doctor_id = data.get("doctor_id", "").strip()
|
||||||
|
|
||||||
|
if hospital and doctor_id:
|
||||||
|
staff = Staff.objects.filter(
|
||||||
|
hospital=hospital, employee_id=doctor_id
|
||||||
|
).first()
|
||||||
|
if not staff:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{
|
||||||
|
"doctor_id": f"Doctor with employee ID '{doctor_id}' not found at this hospital."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
data["_staff"] = staff
|
||||||
|
|
||||||
|
return data
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user