feat: unified reference numbers + feedback modules QA audit
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:
ismail 2026-06-14 14:29:23 +03:00
parent 7dae32d206
commit 7369d08012
357 changed files with 185641 additions and 12476 deletions

View 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

View 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

View 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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -73,6 +73,10 @@ class AppreciationAdmin(admin.ModelAdmin):
'sender', 'sender',
'hospital', 'hospital',
'department', 'department',
'legacy_location',
'legacy_main_section',
'legacy_subsection',
'section',
'category', 'category',
) )
}), }),

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('appreciation', '0004_alter_appreciation_legacy_location_and_more'),
]
operations = []

View File

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

View File

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

View File

@ -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,23 +66,11 @@ 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;">&#127775; 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; }}
.content {{ padding: 30px; }}
.appreciation-box {{ background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0; }}
.personal-note {{ background: #fffbeb; border-left: 4px solid #f59e0b; padding: 20px; margin: 20px 0; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>&#127775; Staff Appreciation</h1>
</div>
<div class="content">
<div class="appreciation-box">
<strong>From:</strong> {sender_name}<br> <strong>From:</strong> {sender_name}<br>
<strong>Hospital:</strong> {appreciation.hospital.name}<br> <strong>Hospital:</strong> {appreciation.hospital.name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''} {f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
@ -85,12 +78,10 @@ def send_appreciation_notification(appreciation):
<strong>Message:</strong><br> <strong>Message:</strong><br>
{appreciation.message_en} {appreciation.message_en}
</div> </div>
{f'<div class="personal-note"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''} {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> <p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
</div> </div>
</div> </div>
</body>
</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}")
if notification_success:
appreciation.notification_sent = True appreciation.notification_sent = True
appreciation.notification_sent_at = timezone.now() appreciation.notification_sent_at = timezone.now()
appreciation.save(update_fields=['notification_sent', 'notification_sent_at']) 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,22 +163,11 @@ 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;">&#127775; 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; }}
.content {{ padding: 30px; }}
.info-box {{ background: #ecfdf5; border-left: 4px solid #10b981; padding: 20px; margin: 20px 0; }}
</style></head>
<body>
<div class="container">
<div class="header">
<h1>&#127775; New Appreciation in Your Department</h1>
</div>
<div class="content">
<div class="info-box">
<strong>Staff Member:</strong> {recipient_name}<br> <strong>Staff Member:</strong> {recipient_name}<br>
<strong>Department:</strong> {dept.name_en or dept.name}<br> <strong>Department:</strong> {dept.name_en or dept.name}<br>
<strong>From:</strong> {sender_name}<br> <strong>From:</strong> {sender_name}<br>
@ -195,8 +179,6 @@ def _send_department_head_notification(appreciation, sender_name):
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p> <p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
</div> </div>
</div> </div>
</body>
</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

View File

@ -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,14 +200,7 @@ 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'
{
"role": "system",
"content": "You are analyzing patient appreciation messages. Always respond with valid JSON.",
},
{
"role": "user",
"content": f'Analyze this patient appreciation message and provide:\n'
f'1. A summary of what the patient appreciated (in English and Arabic)\n' f'1. A summary of what the patient appreciated (in English and Arabic)\n'
f'2. Key themes mentioned\n' f'2. Key themes mentioned\n'
f'3. Suggested category if not already set\n' f'3. Suggested category if not already set\n'
@ -211,9 +213,8 @@ def appreciation_activate(request, pk):
f'- tone: "warm", "formal", or "casual"\n' f'- tone: "warm", "formal", or "casual"\n'
f'- suggested_response_en: Suggested response in English\n' f'- suggested_response_en: Suggested response in English\n'
f'- suggested_response_ar: Suggested response in Arabic', f'- suggested_response_ar: Suggested response in Arabic',
}, system_prompt="You are analyzing patient appreciation messages. Always respond with valid JSON.",
], response_format="json_object",
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)

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('complaints', '0012_alter_complaint_legacy_location_and_more'),
]
operations = []

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

@ -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,10 +594,32 @@ class ComplaintService:
if dept_id not in selected_dept_ids: if dept_id not in selected_dept_ids:
continue continue
champion = None
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:
champion = dept_info.get("champion") champion = dept_info.get("champion")
champion_email = dept_info.get("champion_email") champion_email = dept_info.get("champion_email")
if not champion or not 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 skipped_no_email += 1
continue continue
@ -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

View File

@ -130,30 +130,22 @@ 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, '')
# Send SMS
notification_log = NotificationService.send_sms( notification_log = NotificationService.send_sms(
phone=instance.contact_phone, phone=instance.contact_phone,
message=sms_message, message=sms_message,
@ -163,22 +155,57 @@ def send_complaint_status_change_sms(sender, instance, created, **kwargs):
'reference_number': instance.reference_number, 'reference_number': instance.reference_number,
'old_status': old_status, 'old_status': old_status,
'new_status': new_status, 'new_status': new_status,
'language': 'en' # Default to English 'language': 'en'
} }
) )
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}") logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
# Create complaint update to track SMS 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}")
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,8 +284,10 @@ 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()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">New Complaint Assigned</h2>
<p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p> <p>A new complaint has been assigned to your department <strong>{department.name}</strong>.</p>
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;"> <div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
<p><strong>Reference:</strong> {complaint.reference_number}</p> <p><strong>Reference:</strong> {complaint.reference_number}</p>
@ -265,6 +298,7 @@ PX360 Team""",
<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> <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> <p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
</div> </div>
</div>
""", """,
related_object=complaint, related_object=complaint,
metadata={ metadata={

View File

@ -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,6 +3349,9 @@ 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)
if config["timeline_parent_field"] == "__self__":
timeline_parent = item
else:
timeline_parent = getattr(item, config["timeline_parent_field"]) timeline_parent = getattr(item, config["timeline_parent_field"])
TimelineClass.objects.create( TimelineClass.objects.create(
complaint=timeline_parent, complaint=timeline_parent,
@ -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
View 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")

View 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

View File

@ -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,9 +119,9 @@ 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():
messages.error(request, _("No departments are involved in this complaint. Please add a department first."))
return redirect("complaints:complaint_detail", pk=complaint.pk) return redirect("complaints:complaint_detail", pk=complaint.pk)
if request.method == "POST": if request.method == "POST":
@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,29 +567,27 @@ 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:
icon = "refresh-cw" if item.get("type") == "status_change" else ("message-square" if item.get("type") == "note" else "check-circle-2")
timeline.append({ timeline.append({
"type": item.get("type", "note"), "type": "response",
"icon": icon, "icon": "check-circle-2",
"title": "Status Updated" if item.get("type") == "status_change" else ("Update Received" if item.get("type") == "note" else "Final Resolution"), "title": "Response Sent",
"comment": item.get("comment", ""), "comment": "",
"created_at": item.get("created_at", ""), "created_at": (observation.department_responded_at or observation.updated_at).strftime("%Y-%m-%d %H:%M"),
}) })
info_cards = [ info_cards = [
@ -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})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,10 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('feedback', '0006_alter_feedback_legacy_location_and_more'),
]
operations = []

View File

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

View File

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

View File

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

View File

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

View File

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

View 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