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.messages",
"django.contrib.staticfiles",
"rest_framework",
# Apps
"apps.core",
"apps.accounts",
"apps.organizations",
"apps.complaints",
"apps.observations",
"apps.feedback",
"apps.appreciation",
"apps.dashboard",
"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",
]
@ -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
GMB_CLIENT_SECRETS_FILE = BASE_DIR / "secrets" / "gmb_client_secrets.json"
GMB_REDIRECT_URI = "http://127.0.0.1:8000/social/callback/GO/"
m
# Data upload settings
# 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.",
"level": 60,
},
{
"name": "champion",
"display_name": "Champion",
"description": "Can respond to inquiries and view complaints for their assigned department.",
"level": 55,
},
{
"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")
def is_champion(self):
"""Check if user is Champion"""
return self.has_role("Champion")
"""Check if user is Champion (assigned as champion on any department)"""
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):
"""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."""
elevated_roles = [
"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):
"""Get active source user profile if exists"""
@ -496,7 +508,6 @@ class Role(models.Model):
("hospital_admin", _("Hospital Admin")),
("department_manager", _("Department Manager")),
("director", _("Director")),
("champion", _("Champion")),
("px_management", _("PX Management")),
("px_employee", _("PX Employee")),
("staff", _("Staff")),

View File

@ -315,6 +315,45 @@ class OnboardingService:
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:
"""Service for sending onboarding-related emails"""
@ -333,12 +372,16 @@ class EmailService:
# Build activation URL
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
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
context = {
"user": user,
"activation_url": activation_url,
"expires_at": user.invitation_expires_at,
"days_remaining": days_remaining,
}
subject = render_to_string("accounts/onboarding/invitation_subject.txt", context).strip()
@ -391,12 +434,16 @@ class EmailService:
# Build activation URL
base_url = getattr(settings, "BASE_URL", "http://localhost:8000")
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
context = {
"user": user,
"activation_url": activation_url,
"expires_at": user.invitation_expires_at,
"days_remaining": days_remaining,
}
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")
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
context = {
"user": user,
"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()

View File

@ -25,7 +25,7 @@ from .models import (
UserProvisionalLog,
)
from .permissions import IsPXAdmin, CanManageOnboarding, CanViewOnboarding
from .services import OnboardingService
from .services import OnboardingService, PasswordResetTokenService
User = get_user_model()
@ -152,6 +152,28 @@ def password_reset_view(request):
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):
"""
Custom password reset confirm view with custom template

View File

@ -25,6 +25,7 @@ from .ui_views import (
onboarding_step_content,
onboarding_welcome,
password_reset_view,
password_reset_token_view,
preview_wizard_as_role,
provisional_user_list,
provisional_user_progress,
@ -58,6 +59,7 @@ urlpatterns = [
path("logout/", logout_view, name="logout"),
path("settings/", user_settings, name="settings"),
path("password/reset/", password_reset_view, name="password_reset"),
path("password/reset/<str:token>/", password_reset_token_view, name="password_reset_token"),
path(
"password/reset/confirm/<uidb64>/<token>/",
CustomPasswordResetConfirmView.as_view(),

View File

@ -27,6 +27,11 @@ from .kpi_models import (
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 = {
"medical": [
"medical",
@ -256,25 +261,25 @@ class KPICalculationService:
def _calculate_72h_resolution(cls, report: KPIReport):
"""Calculate 72-Hour Resolution Rate (MOH-2)"""
# 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:
end_date = datetime(report.year + 1, 1, 1)
end_date = _dt(report.year + 1, 1, 1)
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)
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
# Calculate for each month
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
# Get complaints for this month
complaints = Complaint.objects.filter(
@ -340,22 +345,22 @@ class KPICalculationService:
def _calculate_patient_experience(cls, report: KPIReport):
"""Calculate Patient Experience Score (MOH-1)"""
# Get date range
year_start = datetime(report.year, 1, 1)
start_date = datetime(report.year, report.month, 1)
year_start = _dt(report.year, 1, 1)
start_date = _dt(report.year, report.month, 1)
if report.month == 12:
end_date = datetime(report.year + 1, 1, 1)
end_date = _dt(report.year + 1, 1, 1)
else:
end_date = datetime(report.year, report.month + 1, 1)
end_date = _dt(report.year, report.month + 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
# Get completed surveys for patient experience
surveys = SurveyInstance.objects.filter(
@ -406,17 +411,17 @@ class KPICalculationService:
- Denominator: complaints with satisfaction in (satisfied, neutral, dissatisfied)
- Numerator: complaints with satisfaction = 'satisfied'
"""
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -457,7 +462,7 @@ class KPICalculationService:
hospital=report.hospital,
created_at__gte=year_start,
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",
)
@ -468,21 +473,21 @@ class KPICalculationService:
@classmethod
def _calculate_n_pad_001(cls, report: KPIReport):
"""Calculate N-PAD-001 Resolution Rate"""
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
if report.month == 12:
year_end = datetime(report.year + 1, 1, 1)
year_end = _dt(report.year + 1, 1, 1)
else:
year_end = datetime(report.year, report.month + 1, 1)
year_end = _dt(report.year, report.month + 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -527,17 +532,17 @@ class KPICalculationService:
@classmethod
def _calculate_response_rate(cls, report: KPIReport):
"""Calculate Department Response Rate (48h)"""
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -587,7 +592,7 @@ class KPICalculationService:
hospital=report.hospital,
created_at__gte=year_start,
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",
)
@ -598,17 +603,17 @@ class KPICalculationService:
@classmethod
def _calculate_activation_2h(cls, report: KPIReport):
"""Calculate Complaint Activation Within 2 Hours"""
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
# Get complaints with assigned_to (activated)
complaints = Complaint.objects.filter(
@ -655,7 +660,7 @@ class KPICalculationService:
hospital=report.hospital,
created_at__gte=year_start,
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",
)
@ -668,17 +673,17 @@ class KPICalculationService:
"""Calculate Unactivated Filled Complaints Rate"""
from apps.dashboard.models import ComplaintRequest
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -725,7 +730,7 @@ class KPICalculationService:
hospital=report.hospital,
created_at__gte=year_start,
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",
)
@ -736,21 +741,21 @@ class KPICalculationService:
@classmethod
def _calculate_moh_24h(cls, report: KPIReport):
"""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:
end_date = datetime(report.year + 1, 1, 1)
end_date = _dt(report.year + 1, 1, 1)
else:
end_date = datetime(report.year, report.month + 1, 1)
end_date = _dt(report.year, report.month + 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -802,21 +807,21 @@ class KPICalculationService:
@classmethod
def _calculate_chi_48h(cls, report: KPIReport):
"""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:
end_date = datetime(report.year + 1, 1, 1)
end_date = _dt(report.year + 1, 1, 1)
else:
end_date = datetime(report.year, report.month + 1, 1)
end_date = _dt(report.year, report.month + 1, 1)
total_numerator = 0
total_denominator = 0
for month in range(1, 13):
month_start = datetime(report.year, month, 1)
month_start = _dt(report.year, month, 1)
if month == 12:
month_end = datetime(report.year + 1, 1, 1)
month_end = _dt(report.year + 1, 1, 1)
else:
month_end = datetime(report.year, month + 1, 1)
month_end = _dt(report.year, month + 1, 1)
complaints = Complaint.objects.filter(
hospital=report.hospital,
@ -918,7 +923,9 @@ class KPICalculationService:
).count()
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():
total_days = 0
for c in resolved_complaints:
@ -963,8 +970,7 @@ class KPICalculationService:
for loc_type, keywords in location_categories.items():
q_objects = Q()
for keyword in keywords:
q_objects |= Q(location__name_en__icontains=keyword)
q_objects |= Q(main_section__name_en__icontains=keyword)
q_objects |= Q(department__location_type__icontains=keyword)
loc_complaints = complaints.filter(q_objects).distinct()
count = loc_complaints.count()
@ -1058,9 +1064,9 @@ class KPICalculationService:
)
# Calculate resolution time buckets
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
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(
@ -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 = (
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
@ -1526,9 +1532,9 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
resolved_complaints = report.total_numerator
# Get date range
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
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
@ -1537,7 +1543,7 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
created_at__gte=year_start,
created_at__lt=year_end,
complaint_type="complaint",
).select_related("department", "location", "main_section", "source")
).select_related("department", "source")
# Count by status
closed_count = complaints.filter(status=ComplaintStatus.CLOSED).count()
@ -1620,7 +1626,7 @@ Focus on identifying drivers of satisfaction and dissatisfaction."""
# Location breakdown
location_counts = {}
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
# 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 = (
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(
@ -2020,9 +2026,9 @@ Focus on identifying why patients are dissatisfied and provide practical solutio
except KPIReport.DoesNotExist:
pass
year_start = datetime(report.year, 1, 1)
year_start = _dt(report.year, 1, 1)
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 = (
@ -2215,9 +2221,9 @@ Focus on identifying which departments need improvement and practical follow-up
total_complaints = report.total_denominator
activated_within_2h = report.total_numerator
month_start = datetime(report.year, report.month, 1)
month_start = _dt(report.year, report.month, 1)
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(
@ -2388,9 +2394,9 @@ Focus on identifying why activations are delayed and practical solutions."""
total_complaints = report.total_denominator
unactivated_filled = report.total_numerator
month_start = datetime(report.year, report.month, 1)
month_start = _dt(report.year, report.month, 1)
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(

View File

@ -26,7 +26,7 @@ from apps.analytics.kpi_models import KPIReport, KPIReportType
from apps.analytics.kpi_service import KPICalculationService
from apps.complaints.models import Complaint, ComplaintStatus, ComplaintUpdate
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.px_sources.models import PXSource
from apps.surveys.models import (
@ -217,19 +217,19 @@ class Command(BaseCommand):
defaults={"name": d["name"], "status": "active"},
)
for loc in LOCATIONS:
Location.objects.get_or_create(
LegacyLocation.objects.get_or_create(
id=loc["id"],
defaults={"name_en": loc["name_en"], "name_ar": loc["name_ar"]},
)
for ms in MAIN_SECTIONS:
MainSection.objects.get_or_create(
LegacyMainSection.objects.get_or_create(
id=ms["id"],
defaults={"name_en": ms["name_en"], "name_ar": ms["name_ar"]},
)
self.stdout.write(
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
@ -335,8 +335,8 @@ class Command(BaseCommand):
complaints = []
source_list = list(sources.values())
departments = list(Department.objects.filter(hospital=hospital))
locations = list(Location.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]))
locations = list(LegacyLocation.objects.filter(id__in=[l["id"] for l in LOCATIONS]))
main_sections = list(LegacyMainSection.objects.filter(id__in=[m["id"] for m in MAIN_SECTIONS]))
for i in range(self.complaints_per_month):
created_at = self._random_dt(month)
@ -378,8 +378,8 @@ class Command(BaseCommand):
complaint = Complaint(
hospital=hospital,
department=department,
location=location,
main_section=main_section,
legacy_location=location,
legacy_main_section=main_section,
source=source,
title=f"{random.choice(COMPLAINT_TITLES)} ({month}/{i + 1})",
description=random.choice(COMPLAINT_DESCRIPTIONS),

View File

@ -125,11 +125,23 @@ class UnifiedAnalyticsService:
# Check if queryset has hospital/department fields
if hasattr(queryset.model, "hospital"):
if user.is_px_admin():
pass # See all
pass
elif user.is_hospital_admin() and 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:
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:
queryset = queryset.none()
return queryset

View File

@ -173,6 +173,69 @@ def analytics_dashboard(request):
# Status breakdown
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 ============
action_status_counts = actions_queryset.values("status").annotate(count=Count("id"))
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),
"severity_breakdown": serialize_queryset_values(severity_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),
"complaints_by_quarter": serialize_queryset_values(complaints_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
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)
if not tenant:
tenant = getattr(user, "hospital", None)
if tenant:
hospital_id = str(tenant.id)
@ -1006,8 +1088,10 @@ def command_center_api(request):
# Handle department_id (UUID string)
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)
if not tenant:
tenant = getattr(user, "hospital", None)
if tenant:
hospital_id = str(tenant.id)

View File

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

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('organizations', '0001_initial'),
('organizations', '0004_legacylocation_legacymainsection_and_more'),
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)),
('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')),
('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')),
('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')),
('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.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')),
('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')),
],
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
"""
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
@ -25,6 +25,15 @@ class AppreciationStatus(models.TextChoices):
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):
"""Appreciation visibility choices"""
@ -128,29 +137,38 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Organization context
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="appreciations")
location = models.ForeignKey(
"organizations.Location",
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.MainSection",
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.SubSection",
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.Department",
@ -176,6 +194,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
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
is_anonymous = models.BooleanField(default=False, help_text="Hide sender identity from recipient")
@ -202,6 +223,8 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Metadata
metadata = models.JSONField(default=dict, blank=True)
notes = GenericRelation("core.Note")
class Meta:
ordering = ["-created_at"]
indexes = [
@ -217,6 +240,36 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
recipient_name = self.get_recipient_name()
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):
from django.utils.translation import get_language
@ -228,7 +281,7 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""Get recipient's name"""
try:
return str(self.recipient)
except:
except Exception:
return "Unknown"
def get_recipient_email(self):
@ -238,7 +291,7 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
return self.recipient.email
elif hasattr(self.recipient, "user"):
return self.recipient.user.email
except:
except Exception:
pass
return None
@ -249,13 +302,16 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
return self.recipient.phone
elif hasattr(self.recipient, "user"):
return self.recipient.user.phone
except:
except Exception:
pass
return None
def activate(self, activated_by=None):
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.activated_at = timezone.now()
self.activated_by = activated_by
@ -264,6 +320,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
def mark_ai_analyzed(self, analysis_data):
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.ai_analyzed_at = timezone.now()
self.ai_analysis = analysis_data
@ -273,6 +332,9 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""Send appreciation and trigger notification"""
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.sent_at = timezone.now()
self.save(update_fields=["status", "sent_at"])
@ -283,13 +345,15 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
"""Mark appreciation as acknowledged"""
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.acknowledged_at = timezone.now()
self.save(update_fields=["status", "acknowledged_at"])
def send_notification(self):
"""Send notification to recipient"""
# This will be implemented in signals.py
"""Send notification to recipient — handled by post_save signal."""
pass
@ -405,7 +469,7 @@ class UserBadge(UUIDModel, TimeStampedModel):
"""Get recipient's name"""
try:
return str(self.recipient)
except:
except Exception:
return "Unknown"
@ -464,5 +528,5 @@ class AppreciationStats(UUIDModel, TimeStampedModel):
"""Get recipient's name"""
try:
return str(self.recipient)
except:
except Exception:
return "Unknown"

View File

@ -6,11 +6,15 @@ This module handles:
- Updating statistics when appreciations are created
- Checking and awarding badges
"""
import logging
from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
logger = logging.getLogger(__name__)
from apps.appreciation.models import (
Appreciation,
AppreciationBadge,
@ -49,8 +53,9 @@ def send_appreciation_notification(appreciation):
Uses the notification system to send email/SMS/WhatsApp.
Also sends email to department heads.
"""
notification_success = False
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_phone = appreciation.get_recipient_phone()
@ -61,36 +66,22 @@ def send_appreciation_notification(appreciation):
)
html_message = f"""
<!DOCTYPE html>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.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); }}
.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>Hospital:</strong> {appreciation.hospital.name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
{f'<div class="personal-note"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''}
<p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
<div 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);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">&#127775; Staff Appreciation</h2>
<div style="background: #eef6fb; border-left: 4px solid #005696; padding: 20px; margin: 20px 0;">
<strong>From:</strong> {sender_name}<br>
<strong>Hospital:</strong> {appreciation.hospital.name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
{f'<div style="background: #fffbeb; border-left: 4px solid #f59e0b; padding: 20px; margin: 20px 0;"><strong>Personal Note from {sender_name}:</strong><br>{appreciation.custom_message}</div>' if appreciation.custom_message else ''}
<p>Congratulations on this recognition! Your dedication is truly appreciated.</p>
</div>
</body>
</html>
</div>
"""
message_en = f"You've received an appreciation from {sender_name}!"
@ -116,8 +107,9 @@ def send_appreciation_notification(appreciation):
html_message=html_message,
related_object=appreciation,
)
notification_success = True
except Exception as e:
print(f"Failed to send appreciation email: {e}")
logger.error(f"Failed to send appreciation email: {e}")
if recipient_phone:
try:
@ -126,24 +118,27 @@ def send_appreciation_notification(appreciation):
message=message_en,
related_object=appreciation,
)
notification_success = True
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_cc_notifications(appreciation, sender_name, html_message, message_en)
notification_success = True
except ImportError as e:
print(f"Notification service not available: {e}")
logger.warning(f"Notification service not available: {e}")
except Exception as e:
print(f"Error sending appreciation notification: {e}")
logger.error(f"Error sending appreciation notification: {e}")
appreciation.notification_sent = True
appreciation.notification_sent_at = timezone.now()
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
if notification_success:
appreciation.notification_sent = True
appreciation.notification_sent_at = timezone.now()
appreciation.save(update_fields=['notification_sent', 'notification_sent_at'])
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
if not appreciation.department:
@ -168,35 +163,22 @@ def _send_department_head_notification(appreciation, sender_name):
recipient_name = appreciation.get_recipient_name() if appreciation.recipient else "N/A"
html = f"""
<!DOCTYPE html>
<html>
<head><style>
body {{ font-family: 'Segoe UI', Tahoma, sans-serif; background: #f8fafc; margin: 0; padding: 20px; }}
.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); }}
.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>Department:</strong> {dept.name_en or dept.name}<br>
<strong>From:</strong> {sender_name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
<div 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);">
{get_email_header_html()}
<div style="padding: 30px;">
<h2 style="color: #005696; margin-top: 0;">&#127775; New Appreciation in Your Department</h2>
<div style="background: #ecfdf5; border-left: 4px solid #10b981; padding: 20px; margin: 20px 0;">
<strong>Staff Member:</strong> {recipient_name}<br>
<strong>Department:</strong> {dept.name_en or dept.name}<br>
<strong>From:</strong> {sender_name}<br>
{f'<strong>Category:</strong> {appreciation.category.name_en}<br>' if appreciation.category else ''}
<br>
<strong>Message:</strong><br>
{appreciation.message_en}
</div>
<p>A staff member in your department has received an appreciation. Please acknowledge their good work.</p>
</div>
</body>
</html>
</div>
"""
plain_message = (
@ -215,7 +197,7 @@ def _send_department_head_notification(appreciation, sender_name):
related_object=appreciation,
)
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):
@ -239,7 +221,7 @@ def _send_cc_notifications(appreciation, sender_name, html_message, plain_messag
related_object=appreciation,
)
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):
@ -248,12 +230,12 @@ def update_appreciation_stats(instance):
Creates or updates monthly statistics.
"""
# Get current year and month
from django.db.models import F
now = timezone.now()
year = now.year
month = now.month
# Get or create stats record
stats, created = AppreciationStats.objects.get_or_create(
recipient_content_type=instance.recipient_content_type,
recipient_object_id=instance.recipient_object_id,
@ -269,20 +251,18 @@ def update_appreciation_stats(instance):
}
)
# Update received count
stats.received_count += 1
AppreciationStats.objects.filter(pk=stats.pk).update(
received_count=F('received_count') + 1,
sent_count=F('sent_count') + 1,
)
stats.refresh_from_db()
# Update category breakdown
if instance.category:
category_breakdown = stats.category_breakdown or {}
category_id_str = str(instance.category.id)
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)
@ -374,6 +354,31 @@ def check_and_award_badges(instance):
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):
"""
@ -444,26 +449,80 @@ def get_appreciation_count(content_type, object_id, criteria_type):
sent_at__year=now.year,
sent_at__month=now.month,
).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
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):
"""
Check if recipient has appreciation streak for required weeks.
Returns True if streak meets or exceeds required_weeks.
Includes the current partial week.
"""
from datetime import timedelta
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
for i in range(required_weeks):
week_start = now - timedelta(weeks=i+1)
week_end = now - timedelta(weeks=i)
streak = 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, 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(
recipient_content_type=content_type,
recipient_object_id=object_id,
@ -473,8 +532,8 @@ def check_appreciation_streak(content_type, object_id, required_weeks):
).exists()
if has_appreciation:
current_week += 1
streak += 1
else:
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.views.decorators.csrf import csrf_exempt
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.paginator import Paginator
from django.db.models import Q, Count
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")
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 = {
"appreciation": appreciation,
"metadata": metadata,
@ -122,6 +127,10 @@ def appreciation_detail(request, pk):
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
"can_send": appreciation.status in (AppreciationStatus.ACTIVATED, AppreciationStatus.AI_ANALYZED),
"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)
@ -191,29 +200,21 @@ def appreciation_activate(request, pk):
from apps.core.ai_service import AIService
analysis_result = AIService.chat_completion(
messages=[
{
"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'2. Key themes mentioned\n'
f'3. Suggested category if not already set\n'
f'4. Tone analysis\n\n'
f'Message: "{appreciation.message_en}"\n\n'
f'Respond in JSON format with keys:\n'
f'- summary_en: English summary\n'
f'- summary_ar: Arabic summary\n'
f'- themes: List of key themes\n'
f'- tone: "warm", "formal", or "casual"\n'
f'- suggested_response_en: Suggested response in English\n'
f'- suggested_response_ar: Suggested response in Arabic',
},
],
response_format={"type": "json_object"},
prompt=f'Analyze this patient appreciation message and provide:\n'
f'1. A summary of what the patient appreciated (in English and Arabic)\n'
f'2. Key themes mentioned\n'
f'3. Suggested category if not already set\n'
f'4. Tone analysis\n\n'
f'Message: "{appreciation.message_en}"\n\n'
f'Respond in JSON format with keys:\n'
f'- summary_en: English summary\n'
f'- summary_ar: Arabic summary\n'
f'- themes: List of key themes\n'
f'- tone: "warm", "formal", or "casual"\n'
f'- suggested_response_en: Suggested response in English\n'
f'- suggested_response_ar: Suggested response in Arabic',
system_prompt="You are analyzing patient appreciation messages. Always respond with valid JSON.",
response_format="json_object",
)
import json
@ -224,11 +225,8 @@ def appreciation_activate(request, pk):
import logging
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)
@ -304,6 +302,10 @@ def appreciation_acknowledge(request, pk):
messages.error(request, "You can only acknowledge appreciations sent to you.")
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
appreciation.acknowledge()
@ -420,8 +422,31 @@ def my_badges_view(request):
for badge in available_badges:
earned = queryset.filter(badge=badge).exists()
progress = 0
if badge.criteria_type == 'count':
progress = min(100, int((total_received / badge.criteria_value) * 100))
if badge.criteria_type in ('received_count', 'received_month'):
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': badge,
@ -692,7 +717,7 @@ def badge_create(request):
description_ar = request.POST.get('description_ar', '')
icon = request.POST.get('icon', 'fa-award')
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)
order = request.POST.get('order', 0)
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.icon = request.POST.get('icon', 'fa-award')
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.order = request.POST.get('order', 0)
badge.is_active = request.POST.get('is_active') == 'on'
@ -980,6 +1005,13 @@ def public_appreciation_submit(request):
import logging
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:
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()
hospital_id = data.get("hospital", "")
staff_name = data.get("staff_name", "").strip()
location_id = data.get("location", "").strip()
main_section_id = data.get("main_section", "").strip()
subsection_id = data.get("subsection", "").strip()
department_id = data.get("department", "").strip()
section_id = data.get("section", "").strip()
if not contact_name or not contact_phone or not message:
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:
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
from apps.organizations.models import Department, Section, OrgSubSection
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
appreciation = Appreciation(
hospital=hospital,
location=location,
main_section=main_section,
subsection=subsection,
department=department,
section=section,
category=None,
message_en=message,
message_ar="",
@ -1044,7 +1073,7 @@ def public_appreciation_submit(request):
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:
logger.exception("ERROR in public_appreciation_submit")
return JsonResponse({"success": False, "message": str(e)}, status=500)

View File

@ -10,6 +10,7 @@ from .models import (
ComplaintAttachment,
ComplaintCategory,
ComplaintMeeting,
ComplaintPdfSummary,
ComplaintPRInteraction,
ComplaintSLAConfig,
ComplaintThreshold,
@ -134,9 +135,11 @@ class ComplaintAdmin(admin.ModelAdmin):
"priority",
"category",
"source",
"location",
"main_section",
"subsection",
"legacy_location",
"legacy_main_section",
"legacy_subsection",
"section",
"is_overdue",
"hospital",
"created_by",
@ -170,7 +173,7 @@ class ComplaintAdmin(admin.ModelAdmin):
{"fields": ("patient", "patient_name", "file_number", "encounter_id", "incident_date", "contact_phone")},
),
("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",
{
@ -256,9 +259,11 @@ class ComplaintAdmin(admin.ModelAdmin):
"hospital",
"department",
"staff",
"location",
"main_section",
"subsection",
"legacy_location",
"legacy_main_section",
"legacy_subsection",
"section",
"assigned_to",
"resolved_by",
"closed_by",
@ -275,12 +280,12 @@ class ComplaintAdmin(admin.ModelAdmin):
def location_hierarchy(self, obj):
"""Display location hierarchy in admin"""
parts = []
if obj.location:
parts.append(obj.location.name_en or obj.location.name_ar or str(obj.location))
if obj.main_section:
parts.append(obj.main_section.name_en or obj.main_section.name_ar or str(obj.main_section))
if obj.subsection:
parts.append(obj.subsection.name_en or obj.subsection.name_ar or str(obj.subsection))
if obj.legacy_location:
parts.append(obj.legacy_location.name_en or obj.legacy_location.name_ar or str(obj.legacy_location))
if obj.legacy_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.legacy_subsection:
parts.append(obj.legacy_subsection.name_en or obj.legacy_subsection.name_ar or str(obj.legacy_subsection))
if not parts:
return ""
@ -312,8 +317,8 @@ class ComplaintAdmin(admin.ModelAdmin):
"resolved": "success",
"closed": "secondary",
"cancelled": "secondary",
"contacted": "info",
"contacted_no_response": "danger",
"pending_external": "info",
"ovr_pending": "warning",
}
color = colors.get(obj.status, "secondary")
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")
@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)
class ComplaintUpdateAdmin(admin.ModelAdmin):
"""Complaint update admin"""
@ -1008,7 +1028,7 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
"ticket_number",
"source",
"complainant_name",
"main_section",
"legacy_main_section",
"received_date",
"status",
"converted_to_complaint",
@ -1033,7 +1053,7 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
fieldsets = (
("Source", {"fields": ("source", "ticket_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",)}),
("Content", {"fields": ("classification", "content")}),
("Status & Assignment", {"fields": ("status", "assigned_to")}),
@ -1045,4 +1065,4 @@ class GovernmentTicketAdmin(admin.ModelAdmin):
def get_queryset(self, 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.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):
@ -137,31 +137,28 @@ class PublicComplaintForm(forms.ModelForm):
widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}),
)
# Complaint Details - Location Hierarchy
location = forms.ModelChoiceField(
label=_("Location"),
queryset=None,
empty_label=_("Select Location"),
# Location Type and Area
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_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"),
queryset=None,
queryset=Section.objects.none(),
empty_label=_("Select Section"),
required=True,
widget=forms.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"}),
required=False,
widget=forms.Select(attrs={"class": "form-control", "id": "section_select"}),
)
staff_name = forms.CharField(
@ -212,11 +209,11 @@ class PublicComplaintForm(forms.ModelForm):
widget=forms.HiddenInput(),
)
# Source type - always external for public complaints
# Source type - always internal for public complaints
complaint_source_type = forms.ChoiceField(
label=_("Complaint Source Type"),
choices=ComplaintSourceType.choices,
initial=ComplaintSourceType.EXTERNAL,
initial=ComplaintSourceType.INTERNAL,
required=False,
widget=forms.HiddenInput(),
)
@ -240,9 +237,10 @@ class PublicComplaintForm(forms.ModelForm):
"patient_name",
"national_id",
"incident_date",
"location",
"main_section",
"subsection",
"location_type",
"area",
"department",
"section",
"staff_name",
"complaint_details",
"expected_result",
@ -255,46 +253,10 @@ class PublicComplaintForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from apps.organizations.models import Location, MainSection, SubSection
# Initialize cascading dropdowns with empty querysets
self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.objects.none()
self.fields["section"].queryset = Section.objects.none()
self.fields["area"].queryset = Area.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
if "hospital" in self.initial:
hospital_id = self.initial["hospital"]
@ -302,10 +264,23 @@ class PublicComplaintForm(forms.ModelForm):
hospital_id = self.data["hospital"]
if hospital_id:
# Filter departments
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")
def clean_mobile_number(self):
"""Validate mobile number format"""
@ -380,9 +355,9 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
"""
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.
Uses cascading dropdowns for location selection.
Uses cascading dropdowns for department/section selection.
Hospital field visibility:
- PX Admins: See dropdown with all hospitals
@ -402,7 +377,7 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
complaint_source_type = forms.ChoiceField(
label=_("Complaint Source Type"),
choices=ComplaintSourceType.choices,
initial=ComplaintSourceType.EXTERNAL,
initial=ComplaintSourceType.INTERNAL,
required=False,
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"}),
)
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(
label=_("Department"),
queryset=Department.objects.none(),
@ -461,6 +451,14 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
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(
label=_("Staff"),
queryset=Staff.objects.none(),
@ -469,48 +467,6 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
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(
label=_("Description"),
required=True,
@ -537,14 +493,12 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
"patient_name",
"national_id",
"incident_date",
"location_type",
"area",
"hospital",
"department",
"location",
"main_section",
"subsection",
"section",
"staff",
"staff_name",
"encounter_id",
"description",
"expected_result",
]
@ -552,44 +506,16 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
def __init__(self, *args, **kwargs):
# Note: user is handled by HospitalFieldMixin
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
# Initialize cascading dropdowns with empty querysets
self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.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()
self.fields["section"].queryset = Section.objects.none()
self.fields["area"].queryset = Area.objects.none()
# Load active PX sources for optional selection
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
# Now filter departments and staff based on hospital
hospital_id = None
@ -615,6 +541,18 @@ class ComplaintForm(HospitalFieldMixin, forms.ModelForm):
"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):
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"}),
)
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(
label=_("Department (Optional)"),
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"})
)
location = 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(
section = forms.ModelChoiceField(
label=_("Section"),
queryset=None,
empty_label=_("Select Section"),
required=False,
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=False,
widget=forms.Select(attrs={"class": "form-select", "id": "subsectionSelect"}),
widget=forms.Select(attrs={"class": "form-select", "id": "sectionSelect"}),
)
priority = forms.ChoiceField(
@ -771,15 +706,15 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
fields = [
"patient",
"hospital",
"location_type",
"area",
"department",
"subject",
"message",
"contact_name",
"contact_phone",
"contact_email",
"location",
"main_section",
"subsection",
"section",
"priority",
"source",
"is_outgoing",
@ -790,35 +725,17 @@ class InquiryForm(HospitalFieldMixin, forms.ModelForm):
# Note: user is handled by HospitalFieldMixin
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
self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.objects.none()
self.fields["location"].queryset = Location.objects.none()
self.fields["section"].queryset = Section.objects.none()
self.fields["area"].queryset = Area.objects.none()
# Load active PX sources for optional selection
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"].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
if 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(
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")
# 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):
@ -1054,7 +981,38 @@ class PublicInquiryForm(forms.Form):
queryset=Hospital.objects.filter(status="active").order_by("name"),
empty_label=_("Select Hospital"),
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(
@ -1083,6 +1041,36 @@ class PublicInquiryForm(forms.Form):
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):
"""
@ -1246,9 +1234,8 @@ class GovernmentTicketForm(forms.ModelForm):
"complainant_name",
"national_id",
"contact_number",
"location",
"main_section",
"subsection",
"department",
"section",
"received_date",
"classification",
"content",
@ -1286,11 +1273,8 @@ class GovernmentTicketForm(forms.ModelForm):
self.fields["source"].queryset = self.fields["source"].queryset.filter(
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["contact_number"].required = False
self.fields["location"].required = False
self.fields["classification"].required = False
self.fields["assigned_to"].required = False
@ -1298,15 +1282,11 @@ class GovernmentTicketForm(forms.ModelForm):
from apps.core.utils import get_assignable_users
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]:
self.fields["location"].queryset = Location.objects.all()
self.fields["main_section"].queryset = MainSection.objects.all()
self.fields["subsection"].queryset = SubSection.objects.all()
self.fields["section"].queryset = Section.objects.all()
else:
self.fields["location"].queryset = Location.objects.none()
self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.objects.none()
self.fields["section"].queryset = Section.objects.none()
def clean_ticket_number(self):
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)
except PXSource.DoesNotExist:
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.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__)
DEFAULT_HOSPITAL_CODE = "NUZHA"
DEFAULT_HOSPITAL_CODE = "HH-N"
# Header aliases: list of possible names in Excel for each field
HEADER_ALIASES = {
@ -37,12 +38,39 @@ HEADER_ALIASES = {
"sub_dept_name": ["القسم الفرعي"],
"date_received": ["تاريخ إستلام الشكوى"],
"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": ["تاريخ الرد"],
# Complaint details
"staff_name": ["اسم الشخص المشتكى عليه - ان وجد", "اسم الشخص المشتكى عليه"],
"complaint_subject": ["موضوع الشكوى الأساسية"],
"description_ar": ["الشكوى باختصار (عربي)", "محتوى الشكوى (عربي)"],
"description_en": ["الشكوى باختصار English", "محتوى الشكوى (English)"],
"satisfaction": ["توثيق تذكيرات للقسم المشتكى عليه"],
"reminder_date": ["تاريخ التذكير"],
"satisfaction": ["Satisfied/Dissatisfied"],
"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 = {
@ -68,15 +96,17 @@ class Command(BaseCommand):
parser.add_argument("excel_file", type=str)
parser.add_argument("--sheet", type=str, default="JAN")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--hospital-code", type=str, default=DEFAULT_HOSPITAL_CODE)
def handle(self, *args, **options):
self.excel_file = options["excel_file"]
self.sheet_name = options["sheet"]
self.dry_run = options["dry_run"]
self.hospital_code = options["hospital_code"]
self.hospital = self._load_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}")
@ -104,13 +134,14 @@ class Command(BaseCommand):
self.stats = {"processed": 0, "success": 0, "failed": 0}
self.errors = []
self.used_refs = set()
self.unmapped_arabic_depts = {}
self._process_sheet()
self._print_report()
def _load_hospital(self) -> Optional[Hospital]:
try:
return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE)
return Hospital.objects.get(code=self.hospital_code)
except Hospital.DoesNotExist:
return None
@ -161,27 +192,72 @@ class Command(BaseCommand):
created_at = date_received or timezone.now()
if created_at and timezone.is_naive(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"))
reminder_date = self._parse_datetime(row_data.get("reminder_date"))
location = self._resolve_location(row_data.get("location_name"))
main_section = self._resolve_section(row_data.get("main_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"))
# Determine status from timeline dates
status = "open"
if response_date:
if closed_date:
status = "closed"
elif resolved_date:
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:
with transaction.atomic():
complaint = Complaint.objects.create(
reference_number=ref_num,
hospital=self.hospital,
location=location,
main_section=main_section,
subsection=subsection,
department=dept,
section=section_obj,
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),
description=self._build_description(row_data),
patient_name="Unknown",
@ -195,18 +271,36 @@ class Command(BaseCommand):
classification_obj=None,
status=status,
assigned_to=assigned_to_user,
resolved_by=assigned_to_user if response_date else None,
due_at=created_at + timedelta(hours=48),
explanation_requested=bool(date_received),
explanation_requested_at=date_received,
resolved_by=assigned_to_user if resolved_date else None,
resolution_outcome=resolution_outcome,
form_sent_at=form_sent_date,
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,
reminder_sent_at=reminder_date,
due_at=created_at + timedelta(hours=48),
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={
"import_source": "2025_excel_basic",
"import_source": "2025_excel",
"original_sheet": self.sheet_name,
"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)
@ -276,20 +370,20 @@ class Command(BaseCommand):
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:
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:
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:
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]:
if not arabic_name:
@ -355,6 +449,12 @@ class Command(BaseCommand):
self.stdout.write(f"Success: {self.stats['success']}")
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:
self.stdout.write(f"\nErrors: {len(self.errors)}")
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.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.accounts.models import User
@ -30,12 +37,12 @@ from .complaint_taxonomy_mapping import (
get_mapped_category,
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__)
# Default hospital code for all imported complaints
DEFAULT_HOSPITAL_CODE = "NUZHA"
DEFAULT_HOSPITAL_CODE = "HH-N"
# Column mapping: field_name -> column_number (1-based)
COLUMN_MAPPING = {
@ -47,6 +54,18 @@ COLUMN_MAPPING = {
"sub_dept_name": 8, # القسم الفرعي
"date_received": 9, # تاريخ إستلام الشكوى
"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_name": 49, # اسم الشخص المشتكى عليه - ان وجد
"domain": 50, # Domain
@ -57,14 +76,16 @@ COLUMN_MAPPING = {
"description_en": 55, # محتوى الشكوى (English)
"satisfaction": 56, # Satisfied/Dissatisfied
"rightful_side": 57, # The Rightful Side
# Timeline columns
"date_sent": 20, # تم ارسال الشكوى (Complaint Sent/Activated)
"first_reminder": 24, # First Reminder Sent
"second_reminder": 28, # Second Reminder Sent
"escalated_date": 32, # Escalated
"closed_date": 37, # Closed
"resolved_date": 44, # Resolved
"response_date": 41, # تاريخ الرد (Response Date - for explanation received)
"recommendation": 58, # Recommendation/Action plan
}
SATISFACTION_MAP = {
"satisfied": "satisfied",
"dissatisfied": "dissatisfied",
"no response": "no_response",
"no_response": "no_response",
"neutral": "neutral",
"escalated": "escalated",
}
# 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("--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):
self.excel_file = options["excel_file"]
self.sheet_name = options["sheet"]
self.dry_run = options["dry_run"]
self.start_row = options["start_row"]
self.hospital_code = options["hospital_code"]
# Load hospital
self.hospital = self._load_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}"))
@ -145,6 +167,7 @@ class Command(BaseCommand):
self.unmapped_taxonomy = set()
self.unmatched_locations = set()
self.unmatched_departments = set()
self.unmapped_arabic_depts = {}
# Cache for used reference numbers to avoid DB queries
self.used_refs = set()
@ -158,7 +181,7 @@ class Command(BaseCommand):
def _load_hospital(self) -> Optional[Hospital]:
"""Load default hospital by code."""
try:
return Hospital.objects.get(code=DEFAULT_HOSPITAL_CODE)
return Hospital.objects.get(code=self.hospital_code)
except Hospital.DoesNotExist:
return None
@ -209,6 +232,13 @@ class Command(BaseCommand):
main_section = self._resolve_section(row_data.get("main_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
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)
# 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"))
first_reminder = self._parse_datetime(row_data.get("first_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"]:
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:
# Create complaint
with transaction.atomic():
complaint = Complaint.objects.create(
reference_number=ref_num,
hospital=self.hospital,
location=location,
main_section=main_section,
subsection=subsection,
department=dept,
section=section_obj,
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),
description=self._build_description(row_data),
patient_name="Unknown",
@ -282,23 +325,26 @@ class Command(BaseCommand):
assigned_to=assigned_to_user,
resolved_by=assigned_to_user,
resolution_outcome=resolution_outcome,
# Timeline fields
activated_at=date_sent,
form_sent_at=form_sent_date,
activated_at=activated_date,
forwarded_to_dept_at=date_sent,
reminder_sent_at=first_reminder,
second_reminder_sent_at=second_reminder,
escalated_at=escalated_date,
closed_at=closed_date,
resolved_at=resolved_date,
# Explanation tracking
explanation_requested=explanation_requested,
explanation_requested_at=explanation_requested_at,
explanation_received_at=explanation_received_at,
due_at=created_at + timedelta(hours=48),
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),
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)
self.stats["success"] += 1
@ -407,30 +453,30 @@ class Command(BaseCommand):
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."""
if not name_ar:
return None
location = Location.objects.filter(name_ar=name_ar).first()
location = LegacyLocation.objects.filter(name_ar=name_ar).first()
if not location:
self.unmatched_locations.add(name_ar)
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."""
if not name_ar:
return None
# Try Section model
section = MainSection.objects.filter(name_ar=name_ar).first()
section = LegacyMainSection.objects.filter(name_ar=name_ar).first()
if not section:
self.unmatched_departments.add(name_ar)
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."""
if not name_ar:
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]:
"""Resolve staff by employee ID."""
@ -630,11 +676,17 @@ class Command(BaseCommand):
self.stdout.write(f" - {loc}")
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:")
for dept in sorted(self.unmatched_departments):
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:
self.stdout.write("\n--- Errors ---")
self.stdout.write(f"Total errors: {len(self.errors)}")

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
('organizations', '0004_legacylocation_legacymainsection_and_more'),
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')),
('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')),
('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')),
('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')),
('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.legacymainsection')),
('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_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 = [
('complaints', '0002_initial'),
('organizations', '0001_initial'),
('organizations', '0004_legacylocation_legacymainsection_and_more'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='complaint',
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(
model_name='complaintadverseaction',
@ -275,12 +275,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='governmentticket',
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(
model_name='governmentticket',
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(
model_name='governmentticket',
@ -290,7 +290,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='governmentticket',
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(
model_name='inquiry',
@ -345,12 +345,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='inquiry',
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(
model_name='inquiry',
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(
model_name='inquiry',
@ -375,7 +375,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='inquiry',
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(
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 django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
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.organizations.models import LocationType
class ComplaintStatus(models.TextChoices):
@ -29,12 +31,18 @@ class ComplaintStatus(models.TextChoices):
RESOLVED = "resolved", _("Resolved")
CLOSED = "closed", _("Closed")
CANCELLED = "cancelled", _("Cancelled")
CONTACTED = "contacted", _("Contacted")
CONTACTED_NO_RESPONSE = "contacted_no_response", _("Contacted, No Response")
PENDING_EXTERNAL = "pending_external", _("Pending External")
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):
"""Delay reason for 72h closure"""
@ -264,6 +272,15 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
staff = models.ForeignKey(
"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
title = models.CharField(max_length=500)
@ -312,30 +329,60 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
help_text="Level 4: Classification",
)
# Location hierarchy - required fields
location = models.ForeignKey(
"organizations.Location",
# Location hierarchy - legacy fields
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.PROTECT,
related_name="complaints",
null=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(
"organizations.MainSection",
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.PROTECT,
related_name="complaints",
null=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(
"organizations.SubSection",
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.PROTECT,
related_name="complaints",
null=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)
@ -351,7 +398,7 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
complaint_source_type = models.CharField(
max_length=20,
choices=ComplaintSourceType.choices,
default=ComplaintSourceType.EXTERNAL,
default=ComplaintSourceType.INTERNAL,
db_index=True,
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
)
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
assigned_to = models.ForeignKey(
"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"
)
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
reopened_at = models.DateTimeField(null=True, blank=True)
reopened_by = models.ForeignKey(
@ -501,6 +577,12 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
forwarded_to_dept_at = models.DateTimeField(
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")
# Complaint details (Step 1 fields)
@ -538,6 +620,8 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Metadata
metadata = models.JSONField(default=dict, blank=True)
notes = GenericRelation("core.Note")
class Meta:
ordering = ["-created_at"]
indexes = [
@ -557,6 +641,29 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
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):
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)
if not self.reference_number:
from datetime import datetime
import uuid
from apps.core.reference import generate_reference
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
self.reference_number = f"CMP-{today}-{random_suffix}"
self.reference_number = generate_reference("CMP", self.hospital)
if not self.due_at:
self.due_at = self.calculate_sla_due_date()
@ -596,8 +700,20 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
else:
self.national_id_hash = ""
self._sync_department_timestamps()
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):
"""
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):
"""
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
"""
return self.status in [
ComplaintStatus.OPEN,
ComplaintStatus.IN_PROGRESS,
ComplaintStatus.PARTIALLY_RESOLVED,
ComplaintStatus.CONTACTED,
ComplaintStatus.CONTACTED_NO_RESPONSE,
ComplaintStatus.PENDING_EXTERNAL,
]
@ -752,13 +866,6 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
ComplaintStatus.RESOLVED: {"label": _("Resolved"), "slug": "resolved", "progress": 100, "css": "emerald"},
ComplaintStatus.CLOSED: {"label": _("Closed"), "slug": "closed", "progress": 100, "css": "slate"},
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
@ -918,6 +1025,17 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
def is_activated(self):
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):
"""
Get the public tracking URL for this complaint.
@ -957,6 +1075,29 @@ class ComplaintAttachment(UUIDModel, TimeStampedModel):
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):
"""
Complaint update/timeline entry.
@ -1436,28 +1577,69 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
department = models.ForeignKey(
"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 = models.ForeignKey(
"organizations.Location",
# Location - legacy fields
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.MainSection",
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.SubSection",
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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
@ -1547,13 +1729,31 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
("in_progress", _("In Progress")),
("resolved", _("Resolved")),
("closed", _("Closed")),
("contacted", _("Contacted")),
("contacted_no_response", _("Contacted, No Response")),
],
default="open",
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
created_by = models.ForeignKey(
"accounts.User",
@ -1615,6 +1815,12 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
transferred_at = models.DateTimeField(
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(
"accounts.User",
on_delete=models.SET_NULL,
@ -1655,6 +1861,14 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
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
dept_response_sla_due_at = models.DateTimeField(
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"
)
notes = GenericRelation("core.Note")
class Meta:
ordering = ["-created_at"]
verbose_name_plural = "Inquiries"
@ -1797,12 +2013,9 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
def save(self, *args, **kwargs):
if not self.reference_number:
from datetime import datetime
import uuid
from apps.core.reference import generate_reference
today = datetime.now().strftime("%Y%m%d")
random_suffix = str(uuid.uuid4().int)[:6]
self.reference_number = f"INQ-{today}-{random_suffix}"
self.reference_number = generate_reference("INQ", self.hospital)
if not self.due_at:
sla_config = self.get_sla_config()
@ -1834,6 +2047,29 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
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
def short_description_en(self):
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).
Active statuses: open, in_progress
Inactive statuses: resolved, closed, contacted, contacted_no_response
Inactive statuses: resolved, closed
"""
return self.status in ["open", "in_progress"]
@ -2154,6 +2390,17 @@ class ComplaintExplanation(UUIDModel, TimeStampedModel):
"""Count of explanation attachments"""
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):
"""Return the access 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_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)
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(
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")
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:
ordering = ["-is_primary", "-created_at"]
verbose_name = "Complaint Involved Department"
@ -2469,6 +2765,7 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
models.Index(fields=["complaint", "role"]),
models.Index(fields=["department", "response_submitted"]),
models.Index(fields=["department", "forwarded_at"]),
models.Index(fields=["department", "acceptance_status"]),
]
def __str__(self):
@ -2485,6 +2782,20 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
).update(is_primary=False)
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):
"""
@ -3223,32 +3534,57 @@ class GovernmentTicket(UUIDModel, TimeStampedModel):
national_id = models.CharField(max_length=20, blank=True)
contact_number = models.CharField(max_length=20, blank=True)
# Location hierarchy
location = models.ForeignKey(
"organizations.Location",
# Location hierarchy - legacy fields
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.PROTECT,
related_name="government_tickets",
null=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(
"organizations.MainSection",
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.PROTECT,
related_name="government_tickets",
null=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(
"organizations.SubSection",
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.PROTECT,
related_name="government_tickets",
null=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
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):
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_code",
"status",
"patient_contact_status",
"patient_contact_status_at",
"created_by",
"created_by_name",
"assigned_to",
@ -221,9 +223,11 @@ class ComplaintSerializer(serializers.ModelSerializer):
"sla_status",
"reminder_sent_at",
"escalated_at",
"location",
"main_section",
"subsection",
"legacy_location",
"legacy_main_section",
"legacy_subsection",
"section",
"resolution",
"resolution_category",
"resolution_outcome",
@ -476,6 +480,7 @@ class ComplaintListSerializer(serializers.ModelSerializer):
"complaint_source_type_display",
"source_name",
"status",
"patient_contact_status",
"assigned_to_name",
"assigned_at",
"due_at",
@ -545,9 +550,11 @@ class InquirySerializer(serializers.ModelSerializer):
"ai_brief_ar",
"source",
"status",
"location",
"main_section",
"subsection",
"legacy_location",
"legacy_main_section",
"legacy_subsection",
"section",
"is_outgoing",
"outgoing_department",
"is_straightforward",

View File

@ -1,15 +1,26 @@
import logging
from datetime import timedelta
from django.utils import timezone
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.complaints.models import Complaint, ComplaintExplanation, ComplaintStatus, ComplaintUpdate
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):
pass
@ -256,15 +267,14 @@ class ComplaintService:
# Valid status transitions for lifecycle enforcement
VALID_STATUS_TRANSITIONS = {
"open": ["in_progress", "cancelled", "contacted", "contacted_no_response"],
"in_progress": ["partially_resolved", "resolved", "cancelled", "contacted", "contacted_no_response", "pending_external"],
"open": ["in_progress", "cancelled"],
"in_progress": ["partially_resolved", "resolved", "cancelled", "pending_external", "ovr_pending"],
"partially_resolved": ["resolved", "in_progress", "cancelled", "pending_external"],
"resolved": ["closed", "in_progress"],
"closed": ["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"],
"ovr_pending": ["in_progress", "resolved", "cancelled"],
}
@staticmethod
@ -294,9 +304,6 @@ class ComplaintService:
classification=complaint.classification,
subcategory_obj=complaint.subcategory_obj,
classification_obj=complaint.classification_obj,
location=complaint.location,
main_section=complaint.main_section,
subsection=complaint.subsection,
complaint_type=complaint.complaint_type,
complaint_source_type=complaint.complaint_source_type,
priority=complaint.priority,
@ -409,6 +416,14 @@ class ComplaintService:
complaint.pending_external_set_at = timezone.now()
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()
ComplaintUpdate.objects.create(
@ -561,6 +576,7 @@ class ComplaintService:
requested_by,
domain,
request=None,
contact_person_map=None,
):
import secrets
@ -578,12 +594,34 @@ class ComplaintService:
if dept_id not in selected_dept_ids:
continue
champion = dept_info.get("champion")
champion_email = dept_info.get("champion_email")
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:
skipped_no_email += 1
continue
champion = dept_info.get("champion")
champion_email = dept_info.get("champion_email")
if not champion or not champion_email:
dept_obj = Department.objects.filter(id=dept_id).select_related("manager", "manager__staff_profile").first()
if dept_obj and dept_obj.manager:
manager_staff = getattr(dept_obj.manager, 'staff_profile', None)
if manager_staff:
champion = manager_staff
champion_email = manager_staff.email or dept_obj.manager.email
if not champion:
skipped_no_email += 1
continue
staff_names = [s["staff_name"] for s in dept_info["staff_list"]]
@ -604,13 +642,13 @@ class ComplaintService:
champion_link = f"https://{domain}/complaints/{complaint.id}/explain/{champion_token}/"
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)
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:
-----------------------------------
@ -659,6 +697,21 @@ This is an automated message from PX360 Complaint Management System."""
email=champion_email,
subject=champion_subject,
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,
metadata={
"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 = {
"champion_count": champion_count,
"skipped_no_email": skipped_no_email,
@ -727,13 +825,18 @@ This is an automated message from PX360 Complaint Management System."""
"results": results,
},
)
complaint.status = ComplaintStatus.CONTACTED
complaint.save(update_fields=["status", "updated_at"])
complaint.save(update_fields=[
"updated_at",
"sent_to_department", "sent_to_department_at",
"forwarded_to_dept_at",
"explanation_requested", "explanation_requested_at",
])
return {
"champion_count": champion_count,
"skipped_no_email": skipped_no_email,
"results": results,
"manager_count": 0,
}
@staticmethod

View File

@ -130,55 +130,82 @@ def send_complaint_status_change_sms(sender, instance, created, **kwargs):
if old_status == new_status:
return
# Only send SMS if phone number is provided
if not instance.contact_phone:
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no phone number. Skipping SMS.")
# Only send if phone or email is provided
if not instance.contact_phone and not instance.contact_email:
logger.info(f"Complaint #{instance.id} status changed to {new_status} but no contact info. Skipping notification.")
return
# Send SMS notification
# Send SMS + email notification
try:
from apps.notifications.services import NotificationService
# Bilingual SMS messages
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 = {
'resolved': f"PX360: تم حل شكوتك #{instance.reference_number}. شكراً لتعاونكم.",
'closed': f"PX360: تم إغلاق شكوتك #{instance.reference_number}. شكراً لتعاونكم."
}
# Default to English (can be enhanced to detect language)
sms_message = messages_en.get(new_status, '')
# Send SMS
notification_log = NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
'notification_type': 'complaint_status_change',
'reference_number': instance.reference_number,
'old_status': old_status,
'new_status': new_status,
'language': 'en' # Default to English
}
)
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
# Create complaint update to track SMS
from apps.notifications.services import NotificationService, get_email_header_html
from apps.core.utils import build_public_track_url
track_url = build_public_track_url("complaint", instance.reference_number)
status_label = "resolved" if new_status == "resolved" else "closed"
sms_message = f"PX360: Your complaint #{instance.reference_number} has been {status_label}. View response: {track_url}"
if instance.contact_phone:
notification_log = NotificationService.send_sms(
phone=instance.contact_phone,
message=sms_message,
related_object=instance,
metadata={
'notification_type': 'complaint_status_change',
'reference_number': instance.reference_number,
'old_status': old_status,
'new_status': new_status,
'language': 'en'
}
)
logger.info(f"Status change SMS sent to {instance.contact_phone} for complaint #{instance.id}: {old_status} -> {new_status}")
if instance.contact_email:
email_subject = f"PX360: Your complaint #{instance.reference_number} has been {status_label}"
email_body = (
f"Dear Valued Patient,\n\n"
f"Your complaint #{instance.reference_number} has been {status_label}.\n\n"
f"To view the full response, please visit:\n{track_url}\n\n"
f"Thank you for your feedback.\n\n"
f"Reference: {instance.reference_number}\n"
f"This is an automated message from PX 360."
)
NotificationService.send_email(
email=instance.contact_email,
subject=email_subject,
message=email_body,
html_message=f"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Complaint Update: {status_label.title()}</h2>
<p style="margin: 0 0 12px 0;">Dear Valued Patient,</p>
<p style="margin: 0 0 12px 0;">Your complaint <strong>#{instance.reference_number}</strong> has been <strong>{status_label}</strong>.</p>
<p style="margin: 0 0 12px 0;">To view the full response, please click the link below:</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
</div>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Reference: {instance.reference_number}</p>
</div>
</div>
""",
related_object=instance,
metadata={
'notification_type': 'complaint_status_change_email',
'reference_number': instance.reference_number,
}
)
logger.info(f"Status change email sent to {instance.contact_email} for complaint #{instance.id}")
ComplaintUpdate.objects.create(
complaint=instance,
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={
'notification_type': 'complaint_status_change',
'old_status': old_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:
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
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(
f"ComplaintInvolvedDepartment #{instance.id}: No respondent email configured for department "
f"'{instance.department.name}'. Skipping notification."
@ -225,10 +256,10 @@ def notify_champion_on_department_assignment(sender, instance, created, **kwargs
return
try:
from apps.notifications.services import NotificationService
from apps.notifications.services import NotificationService, get_email_header_html
from django.contrib.sites.models import Site
champion = instance.department.respondent.user
champion = instance.department.champion.user
complaint = instance.complaint
department = instance.department
@ -253,17 +284,20 @@ Please review and respond through your department page:
Best regards,
PX360 Team""",
html_message=f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #005696;">New Complaint Assigned</h2>
<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;">
<p><strong>Reference:</strong> {complaint.reference_number}</p>
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
<div 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);">
{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>
<div style="background: #f8fafc; padding: 15px; border-radius: 8px; margin: 15px 0;">
<p><strong>Reference:</strong> {complaint.reference_number}</p>
<p><strong>Title:</strong> {complaint.title or 'No title'}</p>
<p><strong>Patient:</strong> {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}</p>
</div>
<p>Please review and respond through your department page:</p>
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
</div>
<p>Please review and respond through your department page:</p>
<a href="{department_url}" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Department Page</a>
<p style="color: #94a3b8; font-size: 12px; margin-top: 20px;">Best regards,<br>PX360 Team</p>
</div>
""",
related_object=complaint,

View File

@ -671,6 +671,16 @@ def create_action_from_complaint(complaint_id):
@shared_task
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.
@ -931,21 +941,9 @@ def escalate_after_reminder(complaint_id):
"hours_since_reminder": (timezone.now() - complaint.reminder_sent_at).total_seconds() / 3600,
}
# Trigger the regular escalation task
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"])
# Auto-escalation disabled — manual escalation only
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}
@ -1307,6 +1305,8 @@ def analyze_complaint_with_ai(complaint_id):
"department",
"staff",
"title",
"ai_brief_en",
"ai_brief_ar",
"metadata",
]
)
@ -1655,6 +1655,8 @@ def _apply_complaint_ai_analysis(complaint, analysis, emotion_analysis):
"department",
"staff",
"title",
"ai_brief_en",
"ai_brief_ar",
"metadata",
]
)
@ -1788,6 +1790,33 @@ def get_explanation_sla_config(hospital):
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
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.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
context = {
"explanation": explanation,
"complaint": explanation.complaint,
"staff": explanation.staff,
"requested_by": explanation.requested_by,
"complaint": complaint,
"staff": staff,
"requested_by": requested_by,
"sla_hours": sla_hours,
"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]}"
@ -1830,28 +1874,7 @@ def send_explanation_request_email(explanation_id):
html_message = render_to_string("emails/explanation_request.html", context)
# Plain text fallback
message_text = (
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
"""
)
message_text = render_to_string("complaints/emails/explanation_request_en.txt", context)
# Send email
send_mail(
@ -1936,6 +1959,7 @@ def check_overdue_explanation_requests():
if current_level >= max_level:
logger.info(f"Explanation {explanation.id} reached max escalation level {max_level}")
_notify_max_escalation_reached(explanation)
continue
# Calculate hours overdue
@ -3007,7 +3031,7 @@ def notify_staff_new_item(item_type, item_id):
"department_field": "department",
"has_timeline": True,
"timeline_model": "apps.complaints.models.ComplaintUpdate",
"timeline_parent_field": "complaint",
"timeline_parent_field": "__self__",
},
"inquiry": {
"model_path": "apps.complaints.models.Inquiry",
@ -3325,7 +3349,10 @@ This is an automated notification from the PX 360 system.
timeline_module = __import__(timeline_module_path, fromlist=[timeline_model_name])
TimelineClass = getattr(timeline_module, timeline_model_name)
timeline_parent = getattr(item, config["timeline_parent_field"])
if config["timeline_parent_field"] == "__self__":
timeline_parent = item
else:
timeline_parent = getattr(item, config["timeline_parent_field"])
TimelineClass.objects.create(
complaint=timeline_parent,
update_type="note",
@ -3660,32 +3687,9 @@ def check_overdue_inquiry_dept_responses():
):
continue
if dept.manager and dept.manager.email:
try:
NotificationService.send_email(
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,
)
# Auto-escalation disabled — manual escalation only
logger.info(f"Auto-escalation skipped for inquiry {inquiry.reference_number} (disabled)")
continue
if overdue_count > 0 or escalated_count > 0:
logger.info(
@ -3726,8 +3730,8 @@ def send_inquiry_dept_response_reminders():
continue
recipients = []
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
recipients.append(dept.respondent.user)
if dept.champion and dept.champion.user and dept.champion.user.email:
recipients.append(dept.champion.user)
if not recipients:
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(
"involved_staff__staff__department",
"involved_staff__staff__report_to",
"involved_departments__department__respondent",
"involved_departments__department__champion",
"involved_departments__department__manager",
),
pk=pk,
@ -55,25 +55,25 @@ def send_to_department_form(request, pk):
# Build department groups from involved_departments first
involved_departments = complaint.involved_departments.select_related(
"department__respondent", "department__manager"
"department__champion", "department__manager"
).all()
for dept_inv in involved_departments:
dept = dept_inv.department
if not dept or not dept.respondent:
if not dept:
continue
dept_key = str(dept.id)
if dept_key not in department_groups:
champion = dept.respondent
champion_email = champion.email or (champion.user.email if champion.user else None)
champion = dept.champion
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
dept_manager = dept.manager
department_groups[dept_key] = {
"department_id": dept_key,
"department_name": dept.get_localized_name(),
"champion": champion,
"champion_id": str(champion.id),
"champion_name": champion.get_full_name(),
"champion_id": str(champion.id) if champion else None,
"champion_name": champion.get_full_name() if champion else None,
"champion_email": champion_email,
"dept_manager": dept_manager,
"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(),
}
if dept and dept.respondent:
if dept:
dept_key = str(dept.id)
if dept_key in department_groups:
department_groups[dept_key]["staff_list"].append(entry)
else:
# This shouldn't happen if we built department_groups from involved_departments,
# but handle as fallback
champion = dept.respondent
champion_email = champion.email or (champion.user.email if champion.user else None)
champion = dept.champion
champion_email = champion.email or (champion.user.email if champion and champion.user else None) if champion else None
dept_manager = dept.manager
department_groups[dept_key] = {
"department_id": dept_key,
"department_name": dept.get_localized_name(),
"champion": champion,
"champion_id": str(champion.id),
"champion_name": champion.get_full_name(),
"champion_id": str(champion.id) if champion else None,
"champion_name": champion.get_full_name() if champion else None,
"champion_email": champion_email,
"dept_manager": dept_manager,
"dept_manager_id": str(dept_manager.id) if dept_manager else None,
@ -121,10 +119,10 @@ def send_to_department_form(request, pk):
else:
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():
messages.error(request, _("No staff members or departments are involved in this complaint."))
return redirect("complaints:complaint_detail", pk=complaint.pk)
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)
if request.method == "POST":
action = request.POST.get("action", "send")
@ -148,8 +146,26 @@ def send_to_department_form(request, pk):
for dept_id in selected_dept_ids:
dept_info = department_groups.get(dept_id)
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)
contact_person_map = {
d["department_id"]: d.get("contact_person_id", "")
for d in preview_depts
if d.get("contact_person_id")
}
return render(
request,
"complaints/send_to_department_preview.html",
@ -158,11 +174,18 @@ def send_to_department_form(request, pk):
"preview_depts": preview_depts,
"selected_dept_ids": selected_dept_ids,
"request_message": request_message,
"contact_person_map": contact_person_map,
},
)
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)
results = ComplaintService.send_to_department(
complaint,
@ -172,8 +195,13 @@ def send_to_department_form(request, pk):
request.user,
site.domain,
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["skipped_no_email"] > 0:
messages.warning(

View File

@ -8,6 +8,9 @@ from .views import (
ComplaintViewSet,
InquiryViewSet,
complaint_explanation_form,
champion_start_investigation,
staff_investigation_form,
champion_review_answers,
generate_complaint_pdf,
api_locations,
api_sections,
@ -33,6 +36,7 @@ urlpatterns = [
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>/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>/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"),
@ -131,6 +135,9 @@ urlpatterns = [
path("public/api/hospitals/<int:hospital_id>/departments/", api_departments, name="api_departments"),
# Public Explanation Form (No Authentication Required)
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)
path("patient/<str:token>/", ui_views.patient_complaint_portal, name="patient_complaint_portal"),
path(
@ -161,6 +168,7 @@ urlpatterns = [
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>/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
path(
"<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("<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>/escalate/", ui_views.inquiry_escalate, name="inquiry_escalate"),
# Token-based explanation form (No Authentication Required)
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(
"hospital",
"department",
"main_section",
"subsection",
"legacy_main_section",
"legacy_subsection",
"assigned_to",
"resolved_by",
"closed_by",
@ -846,10 +846,10 @@ def export_monthly_calculations(queryset, year, month):
source_display = "Patient"
location_display = ""
if c.main_section:
location_display = c.main_section.name if hasattr(c.main_section, "name") else str(c.main_section)
if c.location:
loc_name = c.location.name if hasattr(c.location, "name") else str(c.location)
if c.legacy_main_section:
location_display = c.legacy_main_section.name if hasattr(c.legacy_main_section, "name") else str(c.legacy_main_section)
if c.legacy_location:
loc_name = c.legacy_location.name if hasattr(c.legacy_location, "name") else str(c.legacy_location)
if 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
loc = "Other"
if c.main_section:
loc_name = c.main_section.name.lower() if hasattr(c.main_section, "name") else str(c.main_section).lower()
if c.legacy_main_section:
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:
loc = "Inpatient"
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",
"hospital",
"department",
"main_section",
"subsection",
"legacy_main_section",
"legacy_subsection",
"assigned_to",
"resolved_by",
"closed_by",
@ -2537,21 +2537,21 @@ def export_historical_excel(queryset, date_start=None, date_end=None):
# Location (main_section or location)
location_name = ""
if c.main_section:
location_name = get_name_ar(c.main_section)
elif c.location:
location_name = get_name_ar(c.location)
if c.legacy_main_section:
location_name = get_name_ar(c.legacy_main_section)
elif c.legacy_location:
location_name = get_name_ar(c.legacy_location)
# Departments
main_dept = ""
if c.main_section:
main_dept = get_name_ar(c.main_section)
if c.legacy_main_section:
main_dept = get_name_ar(c.legacy_main_section)
elif c.department:
main_dept = get_name_ar(c.department)
sub_dept = ""
if c.subsection:
sub_dept = get_name_ar(c.subsection)
if c.legacy_subsection:
sub_dept = get_name_ar(c.legacy_subsection)
# Entered by (assigned_to or created_by)
entered_by = ""

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ Features:
import json
import logging
import re
from typing import Dict, List, Optional, Any
import httpx
@ -45,6 +46,71 @@ class AIService:
SEVERITY_CHOICES = ["low", "medium", "high", "critical"]
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
def _get_api_key(cls) -> str:
return getattr(settings, "OPENROUTER_API_KEY", None) or cls.OPENROUTER_API_KEY
@ -789,11 +855,12 @@ class AIService:
prompt=prompt,
system_prompt=system_prompt,
response_format="json_object",
temperature=0.2, # Lower temperature for consistent classification
temperature=0.2,
max_tokens=2000,
)
# Parse JSON response
result = json.loads(response)
result = cls._safe_json_loads(response)
# Detect complaint type
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 apps.organizations.models import Department, Hospital
from apps.organizations.services import StaffService
from apps.px_action_center.models import PXActionSLAConfig, RoutingRule
from apps.complaints.models import OnCallAdminSchedule
from apps.callcenter.models import CallRecord
from apps.notifications.services import NotificationService
from apps.accounts.services import PasswordResetTokenService
from apps.core.decorators import px_admin_required, admin_required
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:
return JsonResponse({"error": "You can only reset passwords for users in your hospital."}, status=403)
new_password = StaffService.generate_password()
target_user.set_password(new_password)
target_user.save(update_fields=["password"])
if target_user.is_provisional:
return JsonResponse({"error": "Use the onboarding invitation flow for provisional users."}, status=400)
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(
"config/emails/reset_password_email.html",
{
"user": target_user,
"password": new_password,
"login_url": login_url,
"reset_url": reset_url,
},
request=request,
)
plain_message = (
f"Dear {target_user.get_full_name()},\n\n"
f"Your password has been reset by an administrator.\n\n"
f"Your new credentials:\n"
f"Email: {target_user.email}\n"
f"Password: {new_password}\n\n"
f"Please login and change your password immediately.\n"
f"Login URL: {login_url}"
f"Your PX360 password has been reset by an administrator.\n\n"
f"For your security, no password is sent by email. Use the link below to set a new password:\n"
f"{reset_url}\n\n"
f"This link expires in 24 hours. If you did not expect this reset, contact your system administrator."
)
NotificationService.send_email(
@ -250,8 +248,7 @@ def reset_user_password(request, user_id):
return JsonResponse(
{
"success": True,
"message": f"Password has been reset for {target_user.get_full_name()}. A new password has been sent to {target_user.email}.",
"password": new_password,
"message": f"Password reset link has been sent to {target_user.email}.",
"user_name": target_user.get_full_name(),
"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",
"role": "Champion",
"role": "Department Manager",
"first_name": "E2E",
"last_name": "Champion",
"hospital": hospital,
"is_staff": False,
"is_champion": True,
},
]
@ -176,6 +177,26 @@ class Command(BaseCommand):
user.groups.add(group)
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"):
_, created_su = SourceUser.objects.get_or_create(
user=user,

View File

@ -93,10 +93,10 @@ class Command(BaseCommand):
self.stdout.write(self.style.WARNING('\n📧 ORGANIZATIONS EMAILS\n'))
try:
self._send_staff_credentials()
self._send_staff_password_reset()
sent_count += 1
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
# Complaints Emails
@ -325,7 +325,9 @@ class Command(BaseCommand):
"""Example onboarding invitation email (uses template)"""
context = {
'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)
text_content = f"""
@ -353,7 +355,9 @@ class Command(BaseCommand):
"""Example onboarding reminder email (uses template)"""
context = {
'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)
text_content = """
@ -372,7 +376,15 @@ class Command(BaseCommand):
def _send_onboarding_completion(self):
"""Example onboarding completion email (uses template)"""
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)
text_content = """
@ -411,30 +423,32 @@ class Command(BaseCommand):
# ORGANIZATIONS EMAILS
# ========================================================================
def _send_staff_credentials(self):
"""Example staff credentials email (uses template)"""
def _send_staff_password_reset(self):
"""Example staff password reset email (uses template)"""
context = {
'staff_name': 'Dr. Fatima Al-Rashid',
'username': 'fatima.alrashid',
'password': 'TempPass123!',
'login_url': 'https://px360.sa/login',
'staff': type('obj', (object,), {
'get_full_name': lambda: 'Dr. Fatima Al-Rashid',
'email': 'fatima@hospital.com',
})(),
'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)
text_content = f"""
Your PX360 Account Credentials
Your PX360 Account Password Setup
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
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

View File

@ -28,7 +28,7 @@ from django.db import transaction
from django.contrib.auth.models import Group, Permission
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.px_sources.models import PXSource
from apps.surveys.models import SurveyTemplate, SurveyQuestion, QuestionType
@ -120,9 +120,9 @@ class Command(BaseCommand):
self.stdout.write("-" * 70)
if self.dry_run:
hospitals = [
type("Hospital", (), {"code": "NUZHA-DEV", "name": "Nuzha"})(),
type("Hospital", (), {"code": "OLAYA-DEV", "name": "Olaya"})(),
type("Hospital", (), {"code": "SUWAIDI-DEV", "name": "Suwaidi"})(),
type("Hospital", (), {"code": "HH-N", "name": "Al Nuzha"})(),
type("Hospital", (), {"code": "HH-O", "name": "Al Olya"})(),
type("Hospital", (), {"code": "HH-S", "name": "Al Suwaidi"})(),
]
else:
hospitals = Hospital.objects.all()
@ -1387,26 +1387,26 @@ class Command(BaseCommand):
return
for loc in locations_data:
Location.objects.update_or_create(
LegacyLocation.objects.update_or_create(
id=loc["id"],
defaults={"name_ar": loc["name_ar"], "name_en": loc["name_en"]},
)
self.stdout.write(f" ✓ Created/Updated: {len(locations_data)} Locations")
for sec in main_sections_data:
MainSection.objects.update_or_create(
LegacyMainSection.objects.update_or_create(
id=sec["id"],
defaults={"name_ar": sec["name_ar"], "name_en": sec["name_en"]},
)
self.stdout.write(f" ✓ Created/Updated: {len(main_sections_data)} Main Sections")
try:
SubSection.objects.all().delete()
LegacySubSection.objects.all().delete()
except Exception:
self.stdout.write(self.style.WARNING(" ⚠ Skipping SubSection deletion - some are referenced"))
subsections_to_create = [
SubSection(
LegacySubSection(
internal_id=int(item["id"]),
name_en=item["name_en"],
name_ar=item["name_ar"],
@ -1415,7 +1415,7 @@ class Command(BaseCommand):
)
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")
def create_roles_and_groups(self):
@ -2238,9 +2238,9 @@ class Command(BaseCommand):
if not self.dry_run:
self.stdout.write(f"\n Organization: {Organization.objects.count()}")
self.stdout.write(f" Hospitals: {Hospital.objects.count()}")
self.stdout.write(f" Locations: {Location.objects.count()}")
self.stdout.write(f" Main Sections: {MainSection.objects.count()}")
self.stdout.write(f" Sub Sections: {SubSection.objects.count()}")
self.stdout.write(f" Locations: {LegacyLocation.objects.count()}")
self.stdout.write(f" Main Sections: {LegacyMainSection.objects.count()}")
self.stdout.write(f" Sub Sections: {LegacySubSection.objects.count()}")
self.stdout.write(f" Roles: {Role.objects.count()}")
self.stdout.write(f" PX Sources: {PXSource.objects.count()}")
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):
"""
Restrict Department Respondent users to only their department detail page
and inquiry department response pages.
Restrict Department Respondent users to only their department detail page,
inquiry department response pages, and observation department response pages.
"""
ALLOWED_PATH_PREFIXES = [
@ -102,6 +102,7 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
"/core/select-hospital/",
"/organizations/departments/",
"/inquiries/",
"/observations/",
"/api/",
"/health/",
"/admin/",
@ -131,7 +132,7 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
for prefix in self.ALLOWED_PATH_PREFIXES:
if path.startswith(prefix):
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
return HttpResponseForbidden()
@ -153,6 +154,36 @@ class DepartmentRespondentMiddleware(MiddlewareMixin):
return HttpResponseForbidden()
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
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")
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):
"""
Abstract base model for tenant-aware models.
@ -174,3 +198,43 @@ class TenantModel(models.Model):
class Meta:
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
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_track,
public_track_api,
public_set_satisfaction,
api_hospitals,
api_observation_categories,
set_language
set_language,
add_note
)
from . import config_views
@ -36,11 +38,15 @@ urlpatterns = [
path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'),
path('public/observation/submit/', public_observation_submit, name='public_observation_submit'),
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/observation-categories/', api_observation_categories, name='api_observation_categories'),
# Language switching
path('set-language/', set_language, name='set_language'),
# Notes
path('notes/add/', add_note, name='add_note'),
]
# Configuration URLs (separate app_name)

View File

@ -1,3 +1,5 @@
from django.urls import reverse
from apps.accounts.models import User
@ -12,3 +14,15 @@ def get_assignable_users(hospital):
.distinct()
.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 import messages
from django.db import connection
from django.http import JsonResponse
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.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
@ -172,20 +175,20 @@ def public_inquiry_submit(request):
Returns JSON response with reference number.
"""
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
# Get form data
name = request.POST.get("name", "").strip()
email = request.POST.get("email", "").strip()
phone = request.POST.get("phone", "").strip()
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()
subject = request.POST.get("subject", "").strip()
message = request.POST.get("message", "").strip()
location_id = request.POST.get("location", "").strip()
main_section_id = request.POST.get("main_section", "").strip()
subsection_id = request.POST.get("subsection", "").strip()
department_id = request.POST.get("department", "").strip()
section_id = request.POST.get("section", "").strip()
# Validation
errors = []
@ -206,11 +209,12 @@ def public_inquiry_submit(request):
# Validate hospital
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
from apps.organizations.models import Area
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(
hospital=hospital,
contact_name=name,
@ -220,18 +224,18 @@ def public_inquiry_submit(request):
message=message,
category=category,
status="open",
location=location,
main_section=main_section,
subsection=subsection,
area=area,
department=department,
section=section,
location_type=location_type if location_type else "",
)
reference_number = f"INQ-{str(inquiry.id)[:8].upper()}"
inquiry.reference_number = reference_number
inquiry.save(update_fields=["reference_number"])
reference_number = inquiry.reference_number # generated by Inquiry.save() (unified format)
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))
notify_staff_new_item.delay("inquiry", str(inquiry.id))
except Exception:
pass
@ -252,7 +256,7 @@ def public_inquiry_submit(request):
from django.conf import settings
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(
"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}"
send_mail(
subject=subject,
subject=email_subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[settings.DEFAULT_FROM_EMAIL],
@ -383,7 +387,10 @@ def public_track_api(request):
"""
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.
"""
from django.utils.translation import gettext as _
@ -391,8 +398,25 @@ def public_track_api(request):
track_type = request.GET.get("type", "").strip().lower()
reference = request.GET.get("reference", "").strip()
if not track_type or not reference:
return JsonResponse({"found": False, "error": str(_("Type and reference are required."))}, status=400)
if not reference:
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":
return _track_complaint(reference)
@ -405,11 +429,11 @@ def public_track_api(request):
def _track_complaint(reference):
from apps.complaints.models import Complaint
from apps.complaints.models import Complaint, ComplaintInvolvedDepartment
try:
complaint = (
Complaint.objects.select_related("hospital", "department", "location")
Complaint.objects.select_related("hospital", "department", "legacy_location")
.prefetch_related("updates")
.get(reference_number__iexact=reference)
)
@ -420,19 +444,29 @@ def _track_complaint(reference):
ps = complaint.public_status
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]
)
_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 = []
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")
title = "Status Updated" if u.update_type == "status_change" else ("Final Resolution" if u.update_type == "resolution" else "Update Received")
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"
msg = u.message or ""
for internal, public_label in _status_map.items():
msg = msg.replace(internal, public_label)
timeline.append({
"type": u.update_type,
"icon": icon,
"title": title,
"comment": u.comments or "",
"comment": msg,
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
})
@ -450,6 +484,10 @@ def _track_complaint(reference):
else:
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({
"found": True,
"type": "complaint",
@ -461,11 +499,14 @@ def _track_complaint(reference):
"escalated": bool(complaint.escalated_at),
"info_cards": info_cards,
"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):
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()
if not inquiry:
@ -474,25 +515,19 @@ def _track_inquiry(reference):
status_map = {
"open": {"label": "Received", "progress": 15, "css": "amber"},
"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"},
"closed": {"label": "Closed", "progress": 100, "css": "slate"},
}
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 = []
for u in updates:
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")
if inquiry.status in ("resolved", "closed") and (inquiry.department_response_en or inquiry.department_response_ar):
timeline.append({
"type": u.update_type,
"icon": icon,
"title": title,
"comment": u.message or "",
"created_at": u.created_at.strftime("%Y-%m-%d %H:%M"),
"type": "response",
"icon": "check-circle-2",
"title": "Response Sent",
"comment": "",
"created_at": (inquiry.department_responded_at or inquiry.updated_at).strftime("%Y-%m-%d %H:%M"),
})
info_cards = [
@ -520,6 +555,11 @@ def _track_inquiry(reference):
"escalated": bool(inquiry.escalated_at),
"info_cards": info_cards,
"timeline": timeline,
"response": {
"has_response": bool(inquiry.department_response_en or inquiry.department_response_ar),
"en": inquiry.department_response_en or "",
"ar": inquiry.department_response_ar or "",
},
})
@ -527,30 +567,28 @@ def _track_observation(reference):
from apps.observations.models import Observation
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:
return JsonResponse({"found": False, "error": "Observation not found"})
status_progress = {
"new": 15, "triaged": 30, "assigned": 40, "in_progress": 50,
"resolved": 100, "closed": 100, "rejected": 0, "duplicate": 0,
"open": 15, "in_progress": 50,
"resolved": 100, "closed": 100,
}
status_css = {
"new": "sky", "triaged": "teal", "assigned": "teal", "in_progress": "blue",
"resolved": "emerald", "closed": "slate", "rejected": "rose", "duplicate": "slate",
"open": "amber", "in_progress": "blue",
"resolved": "emerald", "closed": "slate",
}
timeline = []
if hasattr(observation, 'public_timeline') and callable(observation.public_timeline):
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({
"type": item.get("type", "note"),
"icon": icon,
"title": "Status Updated" if item.get("type") == "status_change" else ("Update Received" if item.get("type") == "note" else "Final Resolution"),
"comment": item.get("comment", ""),
"created_at": item.get("created_at", ""),
})
if observation.status in ("resolved", "closed") and (observation.department_response_en or observation.department_response_ar):
timeline.append({
"type": "response",
"icon": "check-circle-2",
"title": "Response Sent",
"comment": "",
"created_at": (observation.department_responded_at or observation.updated_at).strftime("%Y-%m-%d %H:%M"),
})
info_cards = [
{"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")},
@ -569,6 +607,11 @@ def _track_observation(reference):
"escalated": False,
"info_cards": info_cards,
"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.
Returns JSON response with tracking code.
"""
from apps.observations.models import Observation, ObservationAttachment, ObservationCategory
from django.shortcuts import get_object_or_404
from apps.observations.models import Observation, ObservationAttachment
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
# Get form data
hospital_id = request.POST.get("hospital", "").strip()
category_id = request.POST.get("category")
severity = request.POST.get("severity", "medium")
title = request.POST.get("title", "").strip()
description = request.POST.get("description", "").strip()
location_text = request.POST.get("location_text", "").strip()
location_id = request.POST.get("location", "").strip()
main_section_id = request.POST.get("main_section", "").strip()
subsection_id = request.POST.get("subsection", "").strip()
location_type = request.POST.get("location_type", "").strip()
area_id = request.POST.get("area", "").strip()
department_id = request.POST.get("department", "").strip()
section_id = request.POST.get("section", "").strip()
incident_datetime = request.POST.get("incident_datetime", "")
reporter_staff_id = request.POST.get("reporter_staff_id", "").strip()
reporter_name = request.POST.get("reporter_name", "").strip()
@ -618,13 +659,11 @@ def public_observation_submit(request):
try:
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
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
from apps.organizations.models import Area
area = Area.objects.filter(id=area_id).first() if area_id else None
# Get client info
def get_client_ip(req):
@ -645,13 +684,14 @@ def public_observation_submit(request):
observation = ObservationService.create_observation(
description=description,
severity=severity,
category=category,
category=None,
title=title,
hospital=hospital,
location_text=location_text,
location=location,
main_section=main_section,
subsection=subsection,
location_type=location_type,
assigned_department=department,
section=section,
area=area,
incident_datetime=incident_datetime if incident_datetime else None,
reporter_staff_id=reporter_staff_id,
reporter_name=reporter_name,
@ -665,6 +705,71 @@ def public_observation_submit(request):
return JsonResponse(
{"success": True, "tracking_code": observation.tracking_code, "observation_id": str(observation.id)}
)
except Exception as e:
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.file_number 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.category.name_en if c.category else "",
c.created_at,

View File

@ -51,7 +51,7 @@ class ComplaintMonthlyService:
).select_related(
"patient", "department", "assigned_to", "created_by",
"domain", "category", "subcategory_obj",
"location", "main_section", "source",
"source",
).prefetch_related("involved_departments__department")
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)
row = 2
ins_total = sum(source[m]["insurance"] for m in active_months)
_write_toprow(ws, row,
round(ext_total / total_all, 3) if total_all else 0, ext_total, "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)
_style_data(ws, row, 1, round(ext_total / total_all, 3) if total_all else 0, PCT_FMT)
_style_data(ws, row, 2, ext_total)
_style_data(ws, row, 3, "External")
row = 3
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})
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)
_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)
@ -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})
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):
_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, 6, ms["moh"])
_style_data(ws, row, 7, ms["chi"])
_style_data(ws, row, 8, ms["insurance"])
_style_data(ws, row, 9, ms["internal"])
_style_data(ws, row, 10, ms["total"])
_style_data(ws, row, 11, ms["moh_pct"], PCT_FMT)
_style_data(ws, row, 12, ms["chi_pct"], PCT_FMT)
_style_data(ws, row, 8, ms["internal"])
_style_data(ws, row, 9, ms["total"])
_style_data(ws, row, 10, ms["moh_pct"], PCT_FMT)
_style_data(ws, row, 11, ms["chi_pct"], PCT_FMT)
row += 2
source_total_items = [
("Internal Complaints", source_totals["internal"]["count"], "% Internal", source_totals["internal"]["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:
_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, 6, d[f"{area}_complaints"])
_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
t = location_ratios["totals"][area]
_style_bold(ws, row, 5, "TOTAL")
_style_bold(ws, row, 6, t["complaints"])
_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
@ -446,7 +439,7 @@ def _write_source_table_sheet(wb, service):
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):
_style_header(ws, row, 5 + i, h)
@ -454,7 +447,7 @@ def _write_source_table_sheet(wb, service):
row += 1
_style_data(ws, row, 5, mr["month"])
_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, 9, mr["support"])
_style_data(ws, row, 10, mr["total"])
@ -462,7 +455,7 @@ def _write_source_table_sheet(wb, service):
row += 1
_style_bold(ws, row, 5, "TOTAL")
_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, 9, dept_type_monthly["totals"]["support"])
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
@ -470,25 +463,25 @@ def _write_source_table_sheet(wb, service):
row += 1
_style_bold(ws, row, 5, "% From total")
_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, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
_style_bold(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
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)
row += 1
_style_data(ws, row, 5, "Persentage")
_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, 9, dept_type_monthly["percentages"]["support"], PCT_FMT)
_style_data(ws, row, 10, dept_type_monthly["percentages"]["total"], PCT_FMT)
row += 1
_style_bold(ws, row, 5, "TOTAL")
_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, 9, dept_type_monthly["totals"]["support"])
_style_bold(ws, row, 10, dept_type_monthly["totals"]["total"])
@ -507,9 +500,9 @@ def _write_escalated_sheet(wb, service):
categories = [
("Medical", "medical"),
("Non-Medical", "non_medical"),
("Administrative", "administrative"),
("Nursing", "nursing"),
("Support Services", "support"),
("Support Services", "support_services"),
]
cat_headers = []
@ -588,16 +581,16 @@ def _write_per_department_sheet(wb, service):
cat_configs = [
("Medical", "medical", 2),
("Non-Medical", "non_medical", 12),
("Administrative", "administrative", 12),
("Nursing", "nursing", 20),
("Support Services", "support", 28),
("Support Services", "support_services", 28),
]
for cat_label, cat_key, col_start in cat_configs:
_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):
_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 + 1, d["moh"])
_style_data(ws, row, col_start + 2, d["chi"])
_style_data(ws, row, col_start + 3, d["insurance"])
_style_data(ws, row, col_start + 4, d["internal"])
_style_data(ws, row, col_start + 5, d["total"])
_style_data(ws, row, col_start + 6, d["escalated"])
_style_data(ws, row, col_start + 7, d["avg_response_days"], NUM_FMT)
_style_data(ws, row, col_start + 3, d["internal"])
_style_data(ws, row, col_start + 4, d["total"])
_style_data(ws, row, col_start + 5, d["escalated"])
_style_data(ws, row, col_start + 6, d["avg_response_days"], NUM_FMT)
total_row = 3 + len(depts)
_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 + 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 + 4, sum(d["internal"] 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 + 6, sum(d["escalated"] 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["total"] for d in depts))
_style_bold(ws, total_row, col_start + 5, sum(d["escalated"] for d in depts))
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"]),
("CHI", source_totals["chi"]),
("Internal", source_totals["internal"]),
("Insurance Co.", source_totals["insurance"]),
("Total", source_totals["total"]),
]
for label, val in source_items:
@ -704,8 +694,8 @@ def _write_per_dept_response_rate_sheets(wb, service):
sheet_configs = [
("8.1 Response Rate Medical", ["medical"]),
("8.2 Response Rate Non-Medical", ["non_medical", "admin"]),
("8.3 RR Nursing&Support", ["nursing", "support"]),
("8.2 Response Rate Administrative", ["administrative", "admin"]),
("8.3 RR Nursing&Support", ["nursing", "support_services"]),
]
for sheet_title, domain_types in sheet_configs:

View File

@ -49,7 +49,7 @@ class ComplaintQuarterlyService:
hospital_id=self.hospital_id,
created_at__range=(dt_start, dt_end),
).select_related(
"department", "domain", "category", "location",
"department",
"source", "assigned_to", "created_by",
)
@ -209,9 +209,20 @@ class ComplaintQuarterlyService:
).count()
relatives = qs.filter(
Q(source__name_en__icontains="relative")
| Q(source__name_en__icontains="family")
| Q(relation_to_patient="relative")
).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] = {
"total": total,
"external": ext,
@ -221,6 +232,7 @@ class ComplaintQuarterlyService:
"insurance": insurance,
"patients": patients,
"relatives": relatives,
"by_source": by_source,
}
return result
@ -232,15 +244,12 @@ class ComplaintQuarterlyService:
for m in self.active_months:
qs = self._month_qs(m)
total = qs.count()
ip = qs.filter(
Q(location__name_en__icontains="inpatient") | Q(location__name_en__icontains="in-patient")
).count()
er = qs.filter(
Q(location__name_en__icontains="emergency") | Q(location__name_en__icontains="er")
).count()
op = total - ip - er
ip = qs.filter(department__location_type="IP").count()
er = qs.filter(department__location_type="ER").count()
op = qs.filter(department__location_type="OP").count()
general = total - ip - er - op
result[m] = {"total": total, "ip": ip, "op": op, "er": er}
result[m] = {"total": total, "ip": ip, "op": op, "er": er, "general": general}
return result
def get_dept_type_breakdown(self):
@ -251,17 +260,19 @@ class ComplaintQuarterlyService:
for m in self.active_months:
qs = self._month_qs(m)
total = qs.count()
medical = qs.filter(domain__domain_type="medical").count()
admin = qs.filter(domain__domain_type="admin").count()
nursing = qs.filter(domain__domain_type="nursing").count()
support = total - medical - admin - nursing
medical = qs.filter(department__category="medical").count()
administrative = qs.filter(department__category="administrative").count()
nursing = qs.filter(department__category="nursing").count()
support = qs.filter(department__category="support_services").count()
other = total - medical - administrative - nursing - support
result[m] = {
"total": total,
"medical": medical,
"admin": admin,
"administrative": administrative,
"nursing": nursing,
"support": support,
"other": other,
}
return result
@ -275,14 +286,22 @@ class ComplaintQuarterlyService:
for c in qs.iterator(chunk_size=2000):
dept_name = c.department.name if c.department else "Unknown"
domain_type = "medical"
if c.domain:
dt = c.domain.domain_type or ""
if dt == "admin" or dt == "non_medical":
domain_type = "non_medical"
if c.department and c.department.category:
dt = c.department.category
if dt in ("admin", "non_medical", "administrative"):
domain_type = "administrative"
elif dt == "nursing":
domain_type = "nursing"
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
if c.complaint_source_type == "internal":
@ -434,8 +453,8 @@ class ComplaintQuarterlyService:
domain_type = c.department.category
elif c.domain:
dt = c.domain.domain_type or ""
if dt == "admin" or dt == "non_medical":
domain_type = "non_medical"
if dt in ("admin", "non_medical", "MANAGEMENT"):
domain_type = "administrative"
elif dt == "nursing":
domain_type = "nursing"
elif dt in ("support", "support_services"):
@ -495,7 +514,7 @@ class ComplaintQuarterlyService:
}
avg_response = {}
for dtype in ["medical", "non_medical", "nursing", "support_services"]:
for dtype in ["medical", "administrative", "nursing", "support_services"]:
depts = categories.get(dtype, [])
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)
@ -526,8 +545,8 @@ class ComplaintQuarterlyService:
domain_type = c.department.category
elif c.domain:
dt = c.domain.domain_type or ""
if dt == "admin" or dt == "non_medical":
domain_type = "non_medical"
if dt in ("admin", "non_medical", "MANAGEMENT"):
domain_type = "administrative"
elif dt == "nursing":
domain_type = "nursing"
elif dt in ("support", "support_services"):
@ -578,8 +597,8 @@ class ComplaintQuarterlyService:
source_label = "Patient's relatives"
location_label = ""
if c.location and c.location.name_en:
location_label = c.location.name_en
if c.department and c.department.location_type:
location_label = c.department.location_type
dept_label = c.department.name if c.department else ""
domain_label = c.domain.name_en if c.domain else ""
@ -768,16 +787,18 @@ class ComplaintQuarterlyService:
result.append({
"month": calendar.month_abbr[m],
"medical": d["medical"],
"admin": d["admin"],
"administrative": d["administrative"],
"nursing": d["nursing"],
"support": d["support"],
"other": d["other"],
"total": d["total"],
})
totals = {
"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),
"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_all = totals["total"]
@ -810,6 +831,7 @@ class ComplaintQuarterlyService:
"source_distribution": {
"external": sum(s["external"] 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": {
"IP": sum(l["ip"] for l in location.values()),
@ -818,9 +840,10 @@ class ComplaintQuarterlyService:
},
"dept_type_distribution": {
"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()),
"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": {
"months": self.active_month_labels,

View File

@ -112,8 +112,8 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
Q(department=user.department) | Q(outgoing_department=user.department)
)
actions_qs = PXAction.objects.filter(department=user.department)
surveys_qs = SurveyInstance.objects.none()
calls_qs = CallCenterInteraction.objects.none()
surveys_qs = SurveyInstance.objects.filter(journey_instance__department=user.department)
calls_qs = CallCenterInteraction.objects.filter(department=user.department)
observations_qs = Observation.objects.filter(assigned_department=user.department)
elif user.is_director():
directed_depts = user.get_directed_departments()
@ -131,6 +131,21 @@ class CommandCenterView(LoginRequiredMixin, TemplateView):
surveys_qs = SurveyInstance.objects.none()
calls_qs = CallCenterInteraction.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:
complaints_qs = Complaint.objects.none()
inquiries_qs = Inquiry.objects.none()
@ -1379,6 +1394,14 @@ def command_center_api(request):
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_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():
directed_depts = user.get_directed_departments()
if directed_depts.exists():
@ -1393,6 +1416,19 @@ def command_center_api(request):
actions_qs = PXAction.objects.none()
surveys_qs = SurveyInstance.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:
complaints_qs = Complaint.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["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_internal_monthly"] = [source_breakdown[m]["internal"] 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": "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)
context["location_rows"] = []
@ -2546,7 +2576,7 @@ def complaint_quarterly_report(request):
})
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)
context["dept_type_rows"].append({
"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"] = []
for mr in dept_type_monthly["months"]:
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"],
"is_total": False, "is_pct": False,
})
context["dept_monthly_rows"].append({
"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"],
"total": dept_type_monthly["totals"]["total"],
"is_total": True, "is_pct": False,
})
context["dept_monthly_rows"].append({
"month": "% From total",
"medical": dept_type_monthly["percentages"]["medical"], "admin": dept_type_monthly["percentages"]["admin"],
"nursing": dept_type_monthly["percentages"]["nursing"], "support": dept_type_monthly["percentages"]["support"],
"total": dept_type_monthly["percentages"]["total"],
"month": "% From Total",
"medical": round(dept_type_monthly["percentages"]["medical"] * 100, 1),
"administrative": round(dept_type_monthly["percentages"]["administrative"] * 100, 1),
"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,
})
@ -2587,7 +2619,7 @@ def complaint_quarterly_report(request):
per_dept = service.get_per_department_breakdown()
cat_configs = [
("Medical", "medical"),
("Non-Medical", "non_medical"),
("Administrative", "administrative"),
("Nursing", "nursing"),
("Support Services", "support_services"),
]
@ -2628,7 +2660,6 @@ def complaint_quarterly_report(request):
totals = {
"moh": sum(d["moh"] 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),
"total": sum(d["total"] 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": "CHI", "count": st["chi"], "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},
]

View File

@ -65,7 +65,22 @@ class FeedbackAdmin(admin.ModelAdmin):
{"fields": ("id", "feedback_type", "title", "message", "category", "subcategory", "rating", "priority")},
),
("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",
{

View File

@ -314,12 +314,12 @@ class PublicSuggestionForm(forms.Form):
def __init__(self, *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["location"].queryset = Location.active_locations()
self.fields["main_section"].queryset = MainSection.objects.none()
self.fields["subsection"].queryset = SubSection.objects.none()
self.fields["location"].queryset = LegacyLocation.active_locations()
self.fields["main_section"].queryset = LegacyMainSection.objects.none()
self.fields["subsection"].queryset = LegacySubSection.objects.none()
location_id = None
if "location" in self.initial:
@ -329,9 +329,9 @@ class PublicSuggestionForm(forms.Form):
if location_id:
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
if "main_section" in self.initial:
@ -340,6 +340,6 @@ class PublicSuggestionForm(forms.Form):
section_id = self.data["main_section"]
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
).order_by("name_en")

View File

@ -11,7 +11,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('organizations', '0001_initial'),
('organizations', '0004_legacylocation_legacymainsection_and_more'),
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)),
('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')),
('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')),
('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')),
('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.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')),
],
options={

View File

@ -11,7 +11,7 @@ class Migration(migrations.Migration):
dependencies = [
('feedback', '0002_initial'),
('organizations', '0001_initial'),
('organizations', '0004_legacylocation_legacymainsection_and_more'),
('px_sources', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
@ -30,7 +30,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='feedback',
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(
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.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -33,6 +34,16 @@ class FeedbackStatus(models.TextChoices):
REVIEWED = "reviewed", _("Reviewed")
ACKNOWLEDGED = "acknowledged", _("Acknowledged")
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):
@ -101,29 +112,38 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Organization
hospital = models.ForeignKey("organizations.Hospital", on_delete=models.CASCADE, related_name="feedbacks")
location = models.ForeignKey(
"organizations.Location",
legacy_location = models.ForeignKey(
"organizations.LegacyLocation",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.MainSection",
legacy_main_section = models.ForeignKey(
"organizations.LegacyMainSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"organizations.SubSection",
legacy_subsection = models.ForeignKey(
"organizations.LegacySubSection",
on_delete=models.SET_NULL,
null=True,
blank=True,
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(
"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)
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
category = models.CharField(max_length=50, choices=FeedbackCategory.choices, db_index=True)
subcategory = models.CharField(max_length=100, blank=True)
@ -216,6 +239,8 @@ class Feedback(UUIDModel, TimeStampedModel, SoftDeleteModel):
# Metadata
metadata = models.JSONField(default=dict, blank=True)
notes = GenericRelation("core.Note")
class Meta:
ordering = ["-created_at"]
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} - 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):
from django.urls import reverse

View File

@ -1,6 +1,7 @@
import logging
from celery import shared_task
from django.utils import timezone
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.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.core.cache import cache
from apps.accounts.models import User
from apps.accounts.services import StaffActivityService
@ -24,6 +25,7 @@ from .models import (
FeedbackStatus,
FeedbackType,
FeedbackCategory,
VALID_FEEDBACK_TRANSITIONS,
)
from .forms import (
FeedbackForm,
@ -141,12 +143,13 @@ def feedback_list(request):
if 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")
if order_by not in ALLOWED_ORDER_BY:
order_by = "-created_at"
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get("page_size", 25))
page_size = min(int(request.GET.get("page_size", 25)), 100)
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
@ -238,6 +241,10 @@ def feedback_detail(request, pk):
"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 = {
"feedback": feedback,
"timeline": timeline,
@ -246,6 +253,10 @@ def feedback_detail(request, pk):
"status_choices": FeedbackStatus.choices,
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
"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)
@ -254,7 +265,7 @@ def feedback_detail(request, pk):
@login_required
@require_http_methods(["GET", "POST"])
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
communication_request = None
@ -275,9 +286,6 @@ def feedback_create(request):
message = request.POST.get("message", "").strip()
hospital_id = request.POST.get("hospital", "")
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 = []
if not contact_name:
@ -295,9 +303,6 @@ def feedback_create(request):
else:
try:
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(
hospital=hospital,
@ -309,9 +314,6 @@ def feedback_create(request):
contact_phone=contact_phone,
is_anonymous=False,
status=FeedbackStatus.SUBMITTED,
location=location,
main_section=main_section,
subsection=subsection,
)
feedback.save()
@ -681,12 +683,6 @@ def export_action_plans(request):
return export_action_plans(qs)
context = {
"feedback": feedback,
}
return render(request, "feedback/feedback_delete_confirm.html", context)
@login_required
@require_http_methods(["POST"])
@ -757,10 +753,27 @@ def feedback_change_status(request, pk):
messages.error(request, "Please select a status.")
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
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
# Handle status-specific logic
if new_status == FeedbackStatus.REVIEWED:
feedback.reviewed_at = timezone.now()
feedback.reviewed_by = request.user
@ -770,6 +783,9 @@ def feedback_change_status(request, pk):
elif new_status == FeedbackStatus.CLOSED:
feedback.closed_at = timezone.now()
feedback.closed_by = request.user
elif new_status == FeedbackStatus.REOPENED:
feedback.closed_at = None
feedback.closed_by = None
feedback.save()
@ -805,6 +821,12 @@ def feedback_add_response(request, pk):
"""Add response to feedback"""
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")
message = request.POST.get("message")
is_internal = request.POST.get("is_internal") == "on"
@ -875,6 +897,12 @@ def public_suggestion_submit(request):
import logging
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:
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:
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 = {
"general": FeedbackCategory.OTHER,
"clinical_care": FeedbackCategory.CLINICAL_CARE,
@ -927,9 +950,6 @@ def public_suggestion_submit(request):
category=category_map.get(category, FeedbackCategory.OTHER),
contact_name=contact_name,
contact_phone=contact_phone,
location=location,
main_section=main_section,
subsection=subsection,
is_anonymous=False,
status=FeedbackStatus.SUBMITTED,
metadata={
@ -968,6 +988,11 @@ def feedback_create_action(request, pk):
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_description = request.POST.get("action_description", "").strip()
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 (
EventMapping,
ExternalAPIKey,
HISEventType,
HISTestPatient,
HISTestVisit,
@ -260,3 +261,71 @@ class HISTestVisitAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
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