diff --git a/.mimocode/plans/1781172520267-calm-canyon.md b/.mimocode/plans/1781172520267-calm-canyon.md new file mode 100644 index 0000000..802fc2d --- /dev/null +++ b/.mimocode/plans/1781172520267-calm-canyon.md @@ -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 diff --git a/.opencode/plans/fix-complaint-reimport.md b/.opencode/plans/fix-complaint-reimport.md new file mode 100644 index 0000000..04643c5 --- /dev/null +++ b/.opencode/plans/fix-complaint-reimport.md @@ -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 diff --git a/.opencode/plans/fix-department-detail-complaints.md b/.opencode/plans/fix-department-detail-complaints.md new file mode 100644 index 0000000..f235949 --- /dev/null +++ b/.opencode/plans/fix-department-detail-complaints.md @@ -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 diff --git a/DEPARTMENT_HIERARCHY_HANDOFF.md b/DEPARTMENT_HIERARCHY_HANDOFF.md new file mode 100644 index 0000000..21083fd --- /dev/null +++ b/DEPARTMENT_HIERARCHY_HANDOFF.md @@ -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= → api_departments_by_category +/dropdowns/sections// → api_sections_by_department +/dropdowns/sub-subsections// → api_sub_subsections_by_section +``` + +### Role management +``` +/department-contacts// → 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//send-to/ → complaint_send_to (POST) +/complaints//escalate/ → complaint_escalate (POST) +/complaints//send-to-department/ → complaint_send_to (POST, same endpoint) +/inquiries//escalate/ → inquiry_escalate (POST) +/inquiries//send-to/ → inquiry_send_to (POST) +/observations//escalate/ → observation_escalate (POST) +/observations//send-to/ → observation_send_to (POST) +``` + +### Legacy dropdown APIs (still active, used by internal forms) +``` +/dropdowns/locations/ → api_locations +/ajax/main-sections/?location= → ajax_main_sections +/ajax/subsections/?location=&main_section= → 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// + → Returns: [{"id": "uuid", "name_en": "Cardiology", ...}, ...] + +GET /organizations/dropdowns/sub-subsections// + → Returns: [{"id": "uuid", "name_en": "ECG Lab", ...}, ...] + +GET /organizations/department-contacts// + → 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. diff --git a/PX360/settings.py b/PX360/settings.py index e845f53..163c203 100644 --- a/PX360/settings.py +++ b/PX360/settings.py @@ -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 diff --git a/apps/accounts/management/commands/create_default_roles.py b/apps/accounts/management/commands/create_default_roles.py index 7c19f94..7cef593 100644 --- a/apps/accounts/management/commands/create_default_roles.py +++ b/apps/accounts/management/commands/create_default_roles.py @@ -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", diff --git a/apps/accounts/migrations/0003_alter_role_name.py b/apps/accounts/migrations/0003_alter_role_name.py new file mode 100644 index 0000000..ae0df48 --- /dev/null +++ b/apps/accounts/migrations/0003_alter_role_name.py @@ -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), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index f791b06..dd39c08 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -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")), diff --git a/apps/accounts/services.py b/apps/accounts/services.py index 4693b7b..544d38a 100644 --- a/apps/accounts/services.py +++ b/apps/accounts/services.py @@ -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() diff --git a/apps/accounts/ui_views.py b/apps/accounts/ui_views.py index 6dd50f6..f3a1ea7 100644 --- a/apps/accounts/ui_views.py +++ b/apps/accounts/ui_views.py @@ -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 diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index c1a5a7d..89368b8 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -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//", password_reset_token_view, name="password_reset_token"), path( "password/reset/confirm///", CustomPasswordResetConfirmView.as_view(), diff --git a/apps/analytics/kpi_service.py b/apps/analytics/kpi_service.py index 8068ff0..7156fc4 100644 --- a/apps/analytics/kpi_service.py +++ b/apps/analytics/kpi_service.py @@ -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( diff --git a/apps/analytics/management/commands/seed_kpi_data.py b/apps/analytics/management/commands/seed_kpi_data.py index 783c27f..8298631 100644 --- a/apps/analytics/management/commands/seed_kpi_data.py +++ b/apps/analytics/management/commands/seed_kpi_data.py @@ -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), diff --git a/apps/analytics/services/analytics_service.py b/apps/analytics/services/analytics_service.py index cf6e19f..40c4856 100644 --- a/apps/analytics/services/analytics_service.py +++ b/apps/analytics/services/analytics_service.py @@ -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 diff --git a/apps/analytics/ui_views.py b/apps/analytics/ui_views.py index 9b7045f..ab9f7a4 100644 --- a/apps/analytics/ui_views.py +++ b/apps/analytics/ui_views.py @@ -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) diff --git a/apps/appreciation/admin.py b/apps/appreciation/admin.py index 557a4e4..518c8c1 100644 --- a/apps/appreciation/admin.py +++ b/apps/appreciation/admin.py @@ -73,6 +73,10 @@ class AppreciationAdmin(admin.ModelAdmin): 'sender', 'hospital', 'department', + 'legacy_location', + 'legacy_main_section', + 'legacy_subsection', + 'section', 'category', ) }), diff --git a/apps/appreciation/migrations/0001_initial.py b/apps/appreciation/migrations/0001_initial.py index b4211ea..b739536 100644 --- a/apps/appreciation/migrations/0001_initial.py +++ b/apps/appreciation/migrations/0001_initial.py @@ -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={ diff --git a/apps/appreciation/migrations/0002_remove_appreciation_location_and_more.py b/apps/appreciation/migrations/0002_remove_appreciation_location_and_more.py new file mode 100644 index 0000000..4898e73 --- /dev/null +++ b/apps/appreciation/migrations/0002_remove_appreciation_location_and_more.py @@ -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', + ), + ] diff --git a/apps/appreciation/migrations/0003_appreciation_legacy_location_and_more.py b/apps/appreciation/migrations/0003_appreciation_legacy_location_and_more.py new file mode 100644 index 0000000..3d0a774 --- /dev/null +++ b/apps/appreciation/migrations/0003_appreciation_legacy_location_and_more.py @@ -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'), + ), + ] diff --git a/apps/appreciation/migrations/0004_alter_appreciation_legacy_location_and_more.py b/apps/appreciation/migrations/0004_alter_appreciation_legacy_location_and_more.py new file mode 100644 index 0000000..81979f8 --- /dev/null +++ b/apps/appreciation/migrations/0004_alter_appreciation_legacy_location_and_more.py @@ -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'), + ), + ] diff --git a/apps/appreciation/migrations/0005_remove_sub_subsection.py b/apps/appreciation/migrations/0005_remove_sub_subsection.py new file mode 100644 index 0000000..0894950 --- /dev/null +++ b/apps/appreciation/migrations/0005_remove_sub_subsection.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('appreciation', '0004_alter_appreciation_legacy_location_and_more'), + ] + + operations = [] diff --git a/apps/appreciation/migrations/0006_appreciation_reference_number_and_more.py b/apps/appreciation/migrations/0006_appreciation_reference_number_and_more.py new file mode 100644 index 0000000..29e8ead --- /dev/null +++ b/apps/appreciation/migrations/0006_appreciation_reference_number_and_more.py @@ -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'), + ), + ] diff --git a/apps/appreciation/models.py b/apps/appreciation/models.py index d6a7cba..1586847 100644 --- a/apps/appreciation/models.py +++ b/apps/appreciation/models.py @@ -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" diff --git a/apps/appreciation/signals.py b/apps/appreciation/signals.py index ef766bf..bc2932b 100644 --- a/apps/appreciation/signals.py +++ b/apps/appreciation/signals.py @@ -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""" - - - - -
-
-

🌟 Staff Appreciation

-
-
-
- From: {sender_name}
- Hospital: {appreciation.hospital.name}
- {f'Category: {appreciation.category.name_en}
' if appreciation.category else ''} -
- Message:
- {appreciation.message_en} -
- {f'
Personal Note from {sender_name}:
{appreciation.custom_message}
' if appreciation.custom_message else ''} -

Congratulations on this recognition! Your dedication is truly appreciated.

+
+ {get_email_header_html()} +
+

🌟 Staff Appreciation

+
+ From: {sender_name}
+ Hospital: {appreciation.hospital.name}
+ {f'Category: {appreciation.category.name_en}
' if appreciation.category else ''} +
+ Message:
+ {appreciation.message_en}
+ {f'
Personal Note from {sender_name}:
{appreciation.custom_message}
' if appreciation.custom_message else ''} +

Congratulations on this recognition! Your dedication is truly appreciated.

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

🌟 New Appreciation in Your Department

-
-
-
- Staff Member: {recipient_name}
- Department: {dept.name_en or dept.name}
- From: {sender_name}
- {f'Category: {appreciation.category.name_en}
' if appreciation.category else ''} -
- Message:
- {appreciation.message_en} -
-

A staff member in your department has received an appreciation. Please acknowledge their good work.

+
+ {get_email_header_html()} +
+

🌟 New Appreciation in Your Department

+
+ Staff Member: {recipient_name}
+ Department: {dept.name_en or dept.name}
+ From: {sender_name}
+ {f'Category: {appreciation.category.name_en}
' if appreciation.category else ''} +
+ Message:
+ {appreciation.message_en}
+

A staff member in your department has received an appreciation. Please acknowledge their good work.

- - +
""" 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""" +
+ {get_email_header_html()} +
+

Congratulations!

+

You've earned the {badge.name_en} badge for receiving {count} appreciations.

+

Keep up the great work!

+
+
+""", + ) + 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 diff --git a/apps/appreciation/ui_views.py b/apps/appreciation/ui_views.py index a9f6f82..e078996 100644 --- a/apps/appreciation/ui_views.py +++ b/apps/appreciation/ui_views.py @@ -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) diff --git a/apps/complaints/admin.py b/apps/complaints/admin.py index 594b789..9f62d74 100644 --- a/apps/complaints/admin.py +++ b/apps/complaints/admin.py @@ -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('{1}', 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") diff --git a/apps/complaints/forms.py b/apps/complaints/forms.py index e345dcb..937ab26 100644 --- a/apps/complaints/forms.py +++ b/apps/complaints/forms.py @@ -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") diff --git a/apps/complaints/management/commands/arabic_dept_mapping.py b/apps/complaints/management/commands/arabic_dept_mapping.py new file mode 100644 index 0000000..766335a --- /dev/null +++ b/apps/complaints/management/commands/arabic_dept_mapping.py @@ -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 diff --git a/apps/complaints/management/commands/backfill_sent_to_department.py b/apps/complaints/management/commands/backfill_sent_to_department.py new file mode 100644 index 0000000..dcd301d --- /dev/null +++ b/apps/complaints/management/commands/backfill_sent_to_department.py @@ -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")) diff --git a/apps/complaints/management/commands/complaint_source_mapping.py b/apps/complaints/management/commands/complaint_source_mapping.py index 9dbeafa..f6fe566 100644 --- a/apps/complaints/management/commands/complaint_source_mapping.py +++ b/apps/complaints/management/commands/complaint_source_mapping.py @@ -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" diff --git a/apps/complaints/management/commands/import_2025_complaints_basic.py b/apps/complaints/management/commands/import_2025_complaints_basic.py index 34ec78c..59757da 100644 --- a/apps/complaints/management/commands/import_2025_complaints_basic.py +++ b/apps/complaints/management/commands/import_2025_complaints_basic.py @@ -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]: diff --git a/apps/complaints/management/commands/import_all_complaints.py b/apps/complaints/management/commands/import_all_complaints.py new file mode 100644 index 0000000..a84498d --- /dev/null +++ b/apps/complaints/management/commands/import_all_complaints.py @@ -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}")) diff --git a/apps/complaints/management/commands/import_historical_complaints.py b/apps/complaints/management/commands/import_historical_complaints.py index 1ddb2d4..f10d69c 100644 --- a/apps/complaints/management/commands/import_historical_complaints.py +++ b/apps/complaints/management/commands/import_historical_complaints.py @@ -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)}") diff --git a/apps/complaints/migrations/0001_initial.py b/apps/complaints/migrations/0001_initial.py index 522d82c..41e98cf 100644 --- a/apps/complaints/migrations/0001_initial.py +++ b/apps/complaints/migrations/0001_initial.py @@ -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')), diff --git a/apps/complaints/migrations/0003_initial.py b/apps/complaints/migrations/0003_initial.py index ec6f9c2..1e6257a 100644 --- a/apps/complaints/migrations/0003_initial.py +++ b/apps/complaints/migrations/0003_initial.py @@ -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', diff --git a/apps/complaints/migrations/0004_add_cancelled_partially_resolved_timestamps.py b/apps/complaints/migrations/0004_add_cancelled_partially_resolved_timestamps.py new file mode 100644 index 0000000..ce8f4a3 --- /dev/null +++ b/apps/complaints/migrations/0004_add_cancelled_partially_resolved_timestamps.py @@ -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), + ), + ] diff --git a/apps/complaints/migrations/0005_add_sent_to_department_fields.py b/apps/complaints/migrations/0005_add_sent_to_department_fields.py new file mode 100644 index 0000000..bbba866 --- /dev/null +++ b/apps/complaints/migrations/0005_add_sent_to_department_fields.py @@ -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), + ), + ] diff --git a/apps/complaints/migrations/0006_data_sent_to_department_backfill.py b/apps/complaints/migrations/0006_data_sent_to_department_backfill.py new file mode 100644 index 0000000..a2711a7 --- /dev/null +++ b/apps/complaints/migrations/0006_data_sent_to_department_backfill.py @@ -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), + ] diff --git a/apps/complaints/migrations/0007_complaintinvolveddepartment_acceptance_notes_and_more.py b/apps/complaints/migrations/0007_complaintinvolveddepartment_acceptance_notes_and_more.py new file mode 100644 index 0000000..6470913 --- /dev/null +++ b/apps/complaints/migrations/0007_complaintinvolveddepartment_acceptance_notes_and_more.py @@ -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'), + ), + ] diff --git a/apps/complaints/migrations/0008_manager_review_workflow.py b/apps/complaints/migrations/0008_manager_review_workflow.py new file mode 100644 index 0000000..21df8db --- /dev/null +++ b/apps/complaints/migrations/0008_manager_review_workflow.py @@ -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', + }, + ), + ] diff --git a/apps/complaints/migrations/0009_fix_manager_review_default.py b/apps/complaints/migrations/0009_fix_manager_review_default.py new file mode 100644 index 0000000..f4d8207 --- /dev/null +++ b/apps/complaints/migrations/0009_fix_manager_review_default.py @@ -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, + ), + ] diff --git a/apps/complaints/migrations/0010_remove_complaint_location_and_more.py b/apps/complaints/migrations/0010_remove_complaint_location_and_more.py new file mode 100644 index 0000000..f68b18f --- /dev/null +++ b/apps/complaints/migrations/0010_remove_complaint_location_and_more.py @@ -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', + ), + ] diff --git a/apps/complaints/migrations/0011_complaint_legacy_location_and_more.py b/apps/complaints/migrations/0011_complaint_legacy_location_and_more.py new file mode 100644 index 0000000..0d92ca0 --- /dev/null +++ b/apps/complaints/migrations/0011_complaint_legacy_location_and_more.py @@ -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'), + ), + + ] diff --git a/apps/complaints/migrations/0012_alter_complaint_legacy_location_and_more.py b/apps/complaints/migrations/0012_alter_complaint_legacy_location_and_more.py new file mode 100644 index 0000000..fd4464c --- /dev/null +++ b/apps/complaints/migrations/0012_alter_complaint_legacy_location_and_more.py @@ -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'), + ), + ] diff --git a/apps/complaints/migrations/0013_remove_sub_subsection.py b/apps/complaints/migrations/0013_remove_sub_subsection.py new file mode 100644 index 0000000..4bde6bf --- /dev/null +++ b/apps/complaints/migrations/0013_remove_sub_subsection.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaints', '0012_alter_complaint_legacy_location_and_more'), + ] + + operations = [] diff --git a/apps/complaints/migrations/0014_complaint_area_complaint_location_type_inquiry_area_and_more.py b/apps/complaints/migrations/0014_complaint_area_complaint_location_type_inquiry_area_and_more.py new file mode 100644 index 0000000..3d6833f --- /dev/null +++ b/apps/complaints/migrations/0014_complaint_area_complaint_location_type_inquiry_area_and_more.py @@ -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'), + ), + ] diff --git a/apps/complaints/migrations/0015_complaintpdfsummary.py b/apps/complaints/migrations/0015_complaintpdfsummary.py new file mode 100644 index 0000000..176dff3 --- /dev/null +++ b/apps/complaints/migrations/0015_complaintpdfsummary.py @@ -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'], + }, + ), + ] diff --git a/apps/complaints/migrations/0016_champion_investigation.py b/apps/complaints/migrations/0016_champion_investigation.py new file mode 100644 index 0000000..2bf1427 --- /dev/null +++ b/apps/complaints/migrations/0016_champion_investigation.py @@ -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')}, + }, + ), + ] diff --git a/apps/complaints/migrations/0017_add_patient_contact_status.py b/apps/complaints/migrations/0017_add_patient_contact_status.py new file mode 100644 index 0000000..8406926 --- /dev/null +++ b/apps/complaints/migrations/0017_add_patient_contact_status.py @@ -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), + ] diff --git a/apps/complaints/migrations/0018_simplify_statuses.py b/apps/complaints/migrations/0018_simplify_statuses.py new file mode 100644 index 0000000..9b11ada --- /dev/null +++ b/apps/complaints/migrations/0018_simplify_statuses.py @@ -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), + ), + ] diff --git a/apps/complaints/migrations/0019_add_response_token.py b/apps/complaints/migrations/0019_add_response_token.py new file mode 100644 index 0000000..c271075 --- /dev/null +++ b/apps/complaints/migrations/0019_add_response_token.py @@ -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), + ), + ] diff --git a/apps/complaints/models.py b/apps/complaints/models.py index 90d209c..2f2e8ea 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -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]}" diff --git a/apps/complaints/serializers.py b/apps/complaints/serializers.py index fb48e77..0cb693d 100644 --- a/apps/complaints/serializers.py +++ b/apps/complaints/serializers.py @@ -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", diff --git a/apps/complaints/services/complaint_service.py b/apps/complaints/services/complaint_service.py index e5a32ff..fe30f7d 100644 --- a/apps/complaints/services/complaint_service.py +++ b/apps/complaints/services/complaint_service.py @@ -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""" +
+ {get_email_header_html()} +
+

Explanation Request - Complaint #{complaint.reference_number}

+

Dear {champion.get_full_name()},

+

We are requesting your assistance in gathering explanations for a complaint involving staff from {dept_info['department_name']}.

+

Please coordinate with the involved staff and submit the explanation.

+ +

This link can only be used once. After submission, it will expire.

+
+
+""", 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 diff --git a/apps/complaints/signals.py b/apps/complaints/signals.py index 643050a..0ba0d4d 100644 --- a/apps/complaints/signals.py +++ b/apps/complaints/signals.py @@ -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""" +
+ {get_email_header_html()} +
+

Complaint Update: {status_label.title()}

+

Dear Valued Patient,

+

Your complaint #{instance.reference_number} has been {status_label}.

+

To view the full response, please click the link below:

+ +

Reference: {instance.reference_number}

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

New Complaint Assigned

-

A new complaint has been assigned to your department {department.name}.

-
-

Reference: {complaint.reference_number}

-

Title: {complaint.title or 'No title'}

-

Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}

+
+ {get_email_header_html()} +
+

New Complaint Assigned

+

A new complaint has been assigned to your department {department.name}.

+
+

Reference: {complaint.reference_number}

+

Title: {complaint.title or 'No title'}

+

Patient: {complaint.patient_name if hasattr(complaint, 'patient_name') else 'N/A'}

+
+

Please review and respond through your department page:

+ View Department Page +

Best regards,
PX360 Team

-

Please review and respond through your department page:

- View Department Page -

Best regards,
PX360 Team

""", related_object=complaint, diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index f7a5a0f..8a0c6ef 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -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 diff --git a/apps/complaints/tests.py b/apps/complaints/tests.py new file mode 100644 index 0000000..d68ece3 --- /dev/null +++ b/apps/complaints/tests.py @@ -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") diff --git a/apps/complaints/tests_inquiry.py b/apps/complaints/tests_inquiry.py new file mode 100644 index 0000000..4b119cb --- /dev/null +++ b/apps/complaints/tests_inquiry.py @@ -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) diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 420f72b..768527d 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -10,6 +10,7 @@ from django.core.paginator import Paginator from django.db.models import Q, Count, Prefetch from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods @@ -40,6 +41,7 @@ from .models import ( ) from .services.complaint_service import ComplaintService, ComplaintServiceError from .forms import ( + ComplaintForm, ComplaintInvolvedDepartmentForm, ComplaintInvolvedStaffForm, DepartmentResponseForm, @@ -58,7 +60,7 @@ def _format_duration(start, end): duration = end - start total_seconds = int(duration.total_seconds()) if total_seconds < 60: - return "< 1m" + return "1m" days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 @@ -69,125 +71,219 @@ def _format_duration(start, end): parts.append(f"{hours}h") if minutes > 0 and days == 0: parts.append(f"{minutes}m") - return " ".join(parts) if parts else "< 1m" + return " ".join(parts) if parts else "1m" def _build_complaint_stage_timeline(complaint): """Build stage timeline with timestamps and durations for a complaint.""" + from .models import ComplaintExplanation, ComplaintInvolvedDepartment, ComplaintUpdate + stages = [] + status_updates = list( + ComplaintUpdate.objects.filter( + complaint=complaint, + update_type__in=["status_change", "escalation", "assignment"], + ).select_related("created_by").order_by("created_at") + ) + + def _find_update(new_status=None, msg_contains=None, update_type=None): + for u in status_updates: + if update_type and u.update_type != update_type: + continue + if new_status and u.new_status != new_status: + continue + if msg_contains and msg_contains not in (u.message or ""): + continue + return u + return None + + def _user_name(user): + return user.get_full_name() if user else None + if complaint.created_at: - stages.append({ - "label": _("Created"), - "timestamp": complaint.created_at, - "color": "bg-slate-400", - }) + performed_by = _user_name(complaint.created_by) if complaint.created_by else str(_("Patient")) + stages.append({"label": _("Created"), "timestamp": complaint.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if complaint.activated_at: - stages.append({ - "label": _("Activated"), - "timestamp": complaint.activated_at, - "duration_from_prev": _format_duration(complaint.created_at, complaint.activated_at), - "color": "bg-blue-500", - }) + activate_update = _find_update(msg_contains="activated", update_type="assignment") + if not activate_update: + activate_update = _find_update(new_status="in_progress") + performed_by = _user_name(activate_update.created_by) if activate_update else None + stages.append({"label": _("Activated"), "timestamp": complaint.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) - if complaint.forwarded_to_dept_at: - stages.append({ - "label": _("Forwarded to Department"), - "timestamp": complaint.forwarded_to_dept_at, - "duration_from_prev": _format_duration(complaint.activated_at or complaint.created_at, complaint.forwarded_to_dept_at), - "color": "bg-purple-500", - }) + forwarded_ts = complaint.forwarded_to_dept_at + if not forwarded_ts: + earliest_dept = ComplaintInvolvedDepartment.objects.filter( + complaint=complaint, + forwarded_at__isnull=False, + ).select_related("department").order_by("forwarded_at").first() + if earliest_dept: + forwarded_ts = earliest_dept.forwarded_at + if forwarded_ts: + fwd_update = _find_update(msg_contains="sent to") + performed_by = _user_name(fwd_update.created_by) if fwd_update else None + stages.append({"label": _("Forwarded to Department"), "timestamp": forwarded_ts, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by}) - if complaint.response_date: - stages.append({ - "label": _("Department Responded"), - "timestamp": complaint.response_date, - "duration_from_prev": _format_duration(complaint.forwarded_to_dept_at or complaint.activated_at, complaint.response_date), - "color": "bg-amber-500", - }) + status_update = _find_update(msg_contains="contacted") + if status_update: + performed_by = _user_name(status_update.created_by) + stages.append({"label": _("Contacted"), "timestamp": status_update.created_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by}) + + if complaint.escalated_at: + esc_update = _find_update(update_type="escalation") + performed_by = _user_name(esc_update.created_by) if esc_update else None + stages.append({"label": _("Escalated"), "timestamp": complaint.escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by}) + + if complaint.escalated_ovr_at: + performed_by = _user_name(complaint.escalated_ovr_by) + stages.append({"label": _("OVR Escalated"), "timestamp": complaint.escalated_ovr_at, "color": "bg-rose-600", "icon": "shield-alert", "performed_by": performed_by}) + + earliest_response = complaint.response_date + first_explanation = None + if not earliest_response: + first_explanation = ComplaintExplanation.objects.filter( + complaint=complaint, + responded_at__isnull=False, + ).select_related("staff").order_by("responded_at").first() + if first_explanation: + earliest_response = first_explanation.responded_at + if earliest_response: + performed_by = _user_name(first_explanation.staff) if first_explanation else None + stages.append({"label": _("Department Responded"), "timestamp": earliest_response, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if complaint.resolved_at: - stages.append({ - "label": _("Resolved"), - "timestamp": complaint.resolved_at, - "duration_from_prev": _format_duration(complaint.response_date or complaint.forwarded_to_dept_at, complaint.resolved_at), - "color": "bg-green-500", - }) + performed_by = _user_name(complaint.resolved_by) + if not performed_by: + resolved_update = _find_update(new_status="resolved") + performed_by = _user_name(resolved_update.created_by) if resolved_update else None + stages.append({"label": _("Resolved"), "timestamp": complaint.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) + + if complaint.partially_resolved_at: + performed_by = _user_name(complaint.partially_resolved_by) + if not performed_by: + pr_update = _find_update(new_status="partially_resolved") + performed_by = _user_name(pr_update.created_by) if pr_update else None + stages.append({"label": _("Partially Resolved"), "timestamp": complaint.partially_resolved_at, "color": "bg-yellow-500", "icon": "alert-circle", "performed_by": performed_by}) + + if complaint.pending_external_set_at: + pe_update = _find_update(new_status="pending_external") + performed_by = _user_name(pe_update.created_by) if pe_update else None + stages.append({"label": _("Pending External"), "timestamp": complaint.pending_external_set_at, "color": "bg-orange-500", "icon": "clock", "performed_by": performed_by}) if complaint.closed_at: - stages.append({ - "label": _("Closed"), - "timestamp": complaint.closed_at, - "duration_from_prev": _format_duration(complaint.resolved_at or complaint.response_date, complaint.closed_at), - "color": "bg-emerald-600", - }) + performed_by = _user_name(complaint.closed_by) + if not performed_by: + closed_update = _find_update(new_status="closed") + performed_by = _user_name(closed_update.created_by) if closed_update else None + stages.append({"label": _("Closed"), "timestamp": complaint.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) - return stages + if complaint.cancelled_at: + performed_by = _user_name(complaint.cancelled_by) + if not performed_by: + cancelled_update = _find_update(new_status="cancelled") + performed_by = _user_name(cancelled_update.created_by) if cancelled_update else None + stages.append({"label": _("Cancelled"), "timestamp": complaint.cancelled_at, "color": "bg-rose-500", "icon": "x-circle", "performed_by": performed_by}) + + stages.sort(key=lambda s: s["timestamp"]) + + for i, stage in enumerate(stages): + stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None + + total_time = None + if len(stages) >= 2: + total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) + + return {"stages": stages, "total_time": total_time} def _build_inquiry_stage_timeline(inquiry): """Build stage timeline with timestamps and durations for an inquiry.""" + from .models import InquiryUpdate + stages = [] + status_updates = list( + InquiryUpdate.objects.filter( + inquiry=inquiry, + ).select_related("created_by").order_by("created_at") + ) + + def _find_update(new_status=None, msg_contains=None, update_type=None): + for u in status_updates: + if update_type and u.update_type != update_type: + continue + if new_status and u.new_status != new_status: + continue + if msg_contains and msg_contains not in (u.message or ""): + continue + return u + return None + + def _user_name(user): + return user.get_full_name() if user else None + if inquiry.created_at: - stages.append({ - "label": _("Created"), - "timestamp": inquiry.created_at, - "color": "bg-slate-400", - }) + performed_by = _user_name(inquiry.created_by) if inquiry.created_by else str(_("Patient")) + stages.append({"label": _("Created"), "timestamp": inquiry.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if inquiry.activated_at: - stages.append({ - "label": _("Activated"), - "timestamp": inquiry.activated_at, - "duration_from_prev": _format_duration(inquiry.created_at, inquiry.activated_at), - "color": "bg-blue-500", - }) + activate_update = _find_update(msg_contains="activated", update_type="assignment") + if not activate_update: + activate_update = _find_update(new_status="in_progress") + performed_by = _user_name(activate_update.created_by) if activate_update else None + stages.append({"label": _("Activated"), "timestamp": inquiry.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) if inquiry.transferred_at: - stages.append({ - "label": _("Transferred to Department"), - "timestamp": inquiry.transferred_at, - "duration_from_prev": _format_duration(inquiry.activated_at or inquiry.created_at, inquiry.transferred_at), - "color": "bg-purple-500", - }) + performed_by = _user_name(inquiry.transferred_by) if hasattr(inquiry, 'transferred_by') else None + if not performed_by: + fwd_update = _find_update(update_type="transferred_to_department") + performed_by = _user_name(fwd_update.created_by) if fwd_update else None + stages.append({"label": _("Transferred to Department"), "timestamp": inquiry.transferred_at, "color": "bg-purple-500", "icon": "send", "performed_by": performed_by}) + + if inquiry.contacted_at: + performed_by = _user_name(inquiry.contacted_by) if hasattr(inquiry, 'contacted_by') else None + stages.append({"label": _("Contacted"), "timestamp": inquiry.contacted_at, "color": "bg-indigo-500", "icon": "phone", "performed_by": performed_by}) + + if inquiry.contacted_nr_at: + performed_by = _user_name(inquiry.contacted_nr_by) if hasattr(inquiry, 'contacted_nr_by') else None + stages.append({"label": _("Contacted No Response"), "timestamp": inquiry.contacted_nr_at, "color": "bg-orange-500", "icon": "phone-off", "performed_by": performed_by}) + + if getattr(inquiry, 'dept_response_escalated_at', None): + esc_update = _find_update(msg_contains="escalat") + performed_by = _user_name(esc_update.created_by) if esc_update else None + stages.append({"label": _("Dept Response Escalated"), "timestamp": inquiry.dept_response_escalated_at, "color": "bg-red-500", "icon": "alert-triangle", "performed_by": performed_by}) if inquiry.department_responded_at: - stages.append({ - "label": _("Department Responded"), - "timestamp": inquiry.department_responded_at, - "duration_from_prev": _format_duration(inquiry.transferred_at or inquiry.activated_at, inquiry.department_responded_at), - "color": "bg-amber-500", - }) + performed_by = _user_name(inquiry.department_responded_by) if hasattr(inquiry, 'department_responded_by') else None + stages.append({"label": _("Department Responded"), "timestamp": inquiry.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if inquiry.responded_at: - stages.append({ - "label": _("Response Sent to Inquirer"), - "timestamp": inquiry.responded_at, - "duration_from_prev": _format_duration(inquiry.department_responded_at or inquiry.transferred_at, inquiry.responded_at), - "color": "bg-cyan-500", - }) + performed_by = _user_name(inquiry.responded_by) if hasattr(inquiry, 'responded_by') else None + stages.append({"label": _("Response Sent"), "timestamp": inquiry.responded_at, "color": "bg-cyan-500", "icon": "reply", "performed_by": performed_by}) _resolved_at = getattr(inquiry, 'resolved_at', None) if _resolved_at: - stages.append({ - "label": _("Resolved"), - "timestamp": _resolved_at, - "duration_from_prev": _format_duration(inquiry.responded_at or inquiry.department_responded_at, _resolved_at), - "color": "bg-green-500", - }) + resolved_update = _find_update(new_status="resolved") + performed_by = _user_name(resolved_update.created_by) if resolved_update else None + stages.append({"label": _("Resolved"), "timestamp": _resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) _closed_at = getattr(inquiry, 'closed_at', None) if _closed_at: - stages.append({ - "label": _("Closed"), - "timestamp": _closed_at, - "duration_from_prev": _format_duration(_resolved_at or inquiry.responded_at, _closed_at), - "color": "bg-emerald-600", - }) + closed_update = _find_update(new_status="closed") + performed_by = _user_name(closed_update.created_by) if closed_update else None + stages.append({"label": _("Closed"), "timestamp": _closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) - return stages + stages.sort(key=lambda s: s["timestamp"]) + + for i, stage in enumerate(stages): + stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None + + total_time = None + if len(stages) >= 2: + total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) + + return {"stages": stages, "total_time": total_time} def can_manage_complaint(user, complaint): @@ -408,6 +504,7 @@ def complaint_list(request): context = { "complaints": page_obj, + "page_obj": page_obj, "stats": base_stats, "hospitals": hospitals, "departments": departments, @@ -460,7 +557,6 @@ def complaint_detail(request, pk): source_user = SourceUser.objects.filter(user=request.user).first() base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" - # OPTIMIZED: Added missing select_related fields and annotated counts complaint_queryset = ( Complaint.objects.select_related( "patient", @@ -475,12 +571,8 @@ def complaint_detail(request, pk): "created_by", "domain", "category", - # ADD: Missing foreign keys that are accessed in template "subcategory_obj", "classification_obj", - "location", - "main_section", - "subsection", ) .prefetch_related( "attachments", @@ -488,14 +580,12 @@ def complaint_detail(request, pk): "involved_departments__department", "involved_departments__assigned_to", "involved_staff__staff__department", - # ADD: Prefetch explanations with their attachments Prefetch( "explanations", queryset=ComplaintExplanation.objects.select_related("staff") .prefetch_related("attachments") .order_by("-created_at"), ), - # ADD: Prefetch adverse actions with related data Prefetch( "adverse_actions", queryset=ComplaintAdverseAction.objects.select_related("reported_by").prefetch_related( @@ -504,7 +594,6 @@ def complaint_detail(request, pk): ), ) .annotate( - # ADD: Annotate counts to avoid N+1 queries in template updates_count=Count("updates", distinct=True), attachments_count=Count("attachments", distinct=True), involved_departments_count=Count("involved_departments", distinct=True), @@ -516,7 +605,6 @@ def complaint_detail(request, pk): complaint = get_object_or_404(complaint_queryset, pk=pk) - # Check access user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and complaint.hospital != user.hospital: @@ -529,100 +617,66 @@ def complaint_detail(request, pk): messages.error(request, "You don't have permission to view this complaint.") return redirect("complaints:complaint_list") - # OPTIMIZED: Use prefetched data instead of re-querying timeline = sorted(complaint.updates.all(), key=lambda x: x.created_at, reverse=True) attachments = sorted(complaint.attachments.all(), key=lambda x: x.created_at, reverse=True) - stage_timeline = _build_complaint_stage_timeline(complaint) - # Get related PX actions (using ContentType since PXAction uses GenericForeignKey) from django.contrib.contenttypes.models import ContentType from apps.px_action_center.models import PXAction complaint_ct = ContentType.objects.get_for_model(Complaint) px_actions = PXAction.objects.filter(content_type=complaint_ct, object_id=complaint.id).order_by("-created_at") - # Get assignable users - from apps.core.utils import get_assignable_users - assignable_users = get_assignable_users(complaint.hospital) + assignable_users = User.objects.filter(is_active=True) + if complaint.hospital: + assignable_users = assignable_users.filter(hospital=complaint.hospital) - # Get departments for the complaint's hospital hospital_departments = [] if complaint.hospital: hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name") - # Check if overdue (only update if necessary) if complaint.is_active_status and not complaint.is_overdue: complaint.check_overdue() - # OPTIMIZED: Use prefetched explanations explanations = complaint.explanations.all() explanation = explanations.first() if explanations else None - - # OPTIMIZED: Attachments are already prefetched explanation_attachments = explanation.attachments.all() if explanation else [] - # OPTIMIZED: Escalation targets - only query managers and direct reports escalation_targets = [] default_escalation_target = None - if complaint.hospital: - # OPTIMIZED: Only query managers and potential escalation targets - # instead of ALL staff in the hospital - from django.db.models import Q + if complaint.department: + dept = Department.objects.filter(pk=complaint.department_id).first() + if dept: + for holder in dept.get_role_holders(): + staff = holder["staff"] + has_user = staff.user and staff.user.is_active + has_email = bool(staff.email) + if has_user or has_email: + escalation_targets.append({ + "staff": staff, + "has_user": has_user, + "user_id": str(staff.user.id) if has_user else "", + "is_manager": False, + "is_line_manager": False, + "role_label": holder["role_label"], + "group": "department_roles", + }) + if escalation_targets: + default_escalation_target = str(escalation_targets[0]["staff"].id) - # Get potential escalation targets: - # 1. The staff's direct manager (if exists) - # 2. Department managers in the hospital - # 3. Hospital admins in the hospital - escalation_targets_qs = ( - Staff.objects.filter(hospital=complaint.hospital, status="active", user__isnull=False, user__is_active=True) - .filter( - # Either is the staff's manager, or is a manager/admin - Q(id=complaint.staff.report_to.id if complaint.staff and complaint.staff.report_to else None) - | Q(user__groups__name__in=["Hospital Admin", "Department Manager"]) - | Q(direct_reports__isnull=False) - ) - .exclude(id=complaint.staff.id if complaint.staff else None) - .select_related("user", "department", "report_to") - .distinct() - .order_by("first_name", "last_name") - ) - - # Build list of escalation targets - for staff in escalation_targets_qs: - escalation_targets.append( - { - "staff": staff, - "has_user": True, # Already filtered for active user - "user_id": str(staff.user.id), - "is_manager": staff.direct_reports.exists(), - "is_line_manager": complaint.staff and complaint.staff.report_to == staff, - } - ) - - # Sort: Line manager first, then other managers, then others - escalation_targets.sort( - key=lambda x: (not x["is_line_manager"], not x["is_manager"], x["staff"].get_full_name()) - ) - - # Set default to staff's line manager if exists - if complaint.staff and complaint.staff.report_to: - default_escalation_target = str(complaint.staff.report_to.id) - - # OPTIMIZED: Use prefetched adverse actions adverse_actions = complaint.adverse_actions.all() from apps.rca.models import RootCauseAnalysis as RCA - complaint_ct = ContentType.objects.get_for_model(Complaint) linked_rcas = RCA.objects.filter( content_type=complaint_ct, object_id=complaint.pk, is_deleted=False ).select_related("assigned_to", "created_by") + stage_timeline = _build_complaint_stage_timeline(complaint) + context = { "complaint": complaint, "timeline": timeline, - "stage_timeline": stage_timeline, "attachments": attachments, "px_actions": px_actions, "assignable_users": assignable_users, @@ -630,6 +684,7 @@ def complaint_detail(request, pk): "base_layout": base_layout, "source_user": source_user, "can_edit": can_manage_complaint(user, complaint), + "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "is_active_status": complaint.is_active_status, "ai_department_suggested": ( bool(complaint.department) @@ -641,9 +696,22 @@ def complaint_detail(request, pk): "explanation_attachments": explanation_attachments, "escalation_targets": escalation_targets, "default_escalation_target": default_escalation_target, + "escalation_email_subject": f"Complaint Escalated - {complaint.reference_number} - {complaint.title or 'N/A'}", + "escalation_email_body": ( + f"Dear Manager,\n\n" + f"This complaint has been escalated and requires your immediate attention.\n\n" + f"Reference: {complaint.reference_number}\n" + f"Title: {complaint.title or 'N/A'}\n" + f"Severity: {complaint.get_severity_display()}\n" + f"Priority: {complaint.get_priority_display()}\n" + f"Status: {complaint.get_status_display()}\n\n" + f"Please review and take appropriate action.\n\n" + f"View: https://{request.get_host()}/organizations/departments/{complaint.department.pk}/" + ), "current_user": user, "adverse_actions": adverse_actions, "linked_rcas": linked_rcas, + "stage_timeline": stage_timeline, "show_delay_reason_closure": ( complaint.delay_reason_closure or complaint.is_overdue @@ -652,19 +720,55 @@ def complaint_detail(request, pk): ), } - _status_label_map = dict(ComplaintStatus.choices) - _valid_next = ComplaintService.VALID_STATUS_TRANSITIONS.get(complaint.status, []) - context["available_transitions"] = [(s, _status_label_map.get(s, s)) for s in _valid_next] + from django.contrib.contenttypes.models import ContentType + complaint_ct = ContentType.objects.get_for_model(complaint) + context["content_type_id"] = complaint_ct.pk + context["object_id"] = complaint.pk + context["notes"] = complaint.notes.select_related("created_by").all() + context["notes_count"] = context["notes"].count() return render(request, "complaints/complaint_detail.html", context) +@login_required +@require_http_methods(["POST"]) +def complaint_assign(request, pk): + """Assign complaint to user - Admin or currently assigned user""" + complaint = get_object_or_404(Complaint, pk=pk) + + user_id = request.POST.get("user_id") + if not user_id: + messages.error(request, "Please select a user to assign.") + return redirect("complaints:complaint_detail", pk=pk) + + try: + assignee = User.objects.get(id=user_id) + old_status = complaint.status + ComplaintService.assign(complaint, assignee, request.user, request=request) + except User.DoesNotExist: + messages.error(request, "User not found.") + return redirect("complaints:complaint_detail", pk=pk) + except ComplaintServiceError as e: + messages.error(request, str(e)) + return redirect("complaints:complaint_detail", pk=pk) + + try: + from apps.notifications.settings_service import NotificationServiceWithSettings + NotificationServiceWithSettings.send_complaint_assigned( + assignee.email if assignee.email else None, + complaint, + ) + except Exception: + pass + + messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") + return redirect("complaints:complaint_detail", pk=pk) + + @login_required @require_http_methods(["GET", "POST"]) def complaint_create(request): """Create new complaint with AI-powered classification""" - from apps.complaints.forms import ComplaintForm - # Determine base layout based on user type from apps.px_sources.models import SourceUser @@ -735,14 +839,7 @@ def complaint_create(request): except (ValueError, AttributeError): pass - # Generate unique reference number: CMP-YYYYMMDD-XXXXX - import uuid - from datetime import datetime - - today = datetime.now().strftime("%Y%m%d") - random_suffix = str(uuid.uuid4().int)[:6] - complaint.reference_number = f"CMP-{today}-{random_suffix}" - + # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) complaint.save() comm_req_id = request.POST.get("comm_req") @@ -806,41 +903,6 @@ def complaint_create(request): return render(request, "complaints/complaint_form.html", context) -@login_required -@require_http_methods(["POST"]) -def complaint_assign(request, pk): - """Assign complaint to user - Admin or currently assigned user""" - complaint = get_object_or_404(Complaint, pk=pk) - - user_id = request.POST.get("user_id") - if not user_id: - messages.error(request, "Please select a user to assign.") - return redirect("complaints:complaint_detail", pk=pk) - - try: - assignee = User.objects.get(id=user_id) - old_status = complaint.status - ComplaintService.assign(complaint, assignee, request.user, request=request) - except User.DoesNotExist: - messages.error(request, "User not found.") - return redirect("complaints:complaint_detail", pk=pk) - except ComplaintServiceError as e: - messages.error(request, str(e)) - return redirect("complaints:complaint_detail", pk=pk) - - try: - from apps.notifications.settings_service import NotificationServiceWithSettings - NotificationServiceWithSettings.send_complaint_assigned( - assignee.email if assignee.email else None, - complaint, - ) - except Exception: - pass - - messages.success(request, f"Complaint assigned to {assignee.get_full_name()}.") - return redirect("complaints:complaint_detail", pk=pk) - - @login_required @require_http_methods(["POST"]) def complaint_send_to(request, pk): @@ -849,7 +911,7 @@ def complaint_send_to(request, pk): """ from django.http import JsonResponse from .models import ComplaintInvolvedDepartment - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html complaint = get_object_or_404(Complaint, pk=pk) user = request.user @@ -891,10 +953,16 @@ def complaint_send_to(request, pk): subject=f"Complaint Assigned - {complaint.reference_number}", message=f"You have been assigned to complaint #{complaint.reference_number}.", html_message=f""" -

You have been assigned to complaint #{complaint.reference_number}.

-

Title: {complaint.title or 'N/A'}

- {f'

Note: {note}

' if note else ''} -

View Complaint

+
+ {get_email_header_html()} +
+

Complaint Assigned

+

You have been assigned to complaint #{complaint.reference_number}.

+

Title: {complaint.title or 'N/A'}

+ {f'

Note: {note}

' if note else ''} +

View Complaint

+
+
""", related_object=complaint, ) @@ -910,57 +978,116 @@ def complaint_send_to(request, pk): }, status=400) try: - department = Department.objects.get(id=department_id, status="active") + department = Department.objects.select_related("champion", "manager").get(id=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) - # Create involved department record - involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create( - complaint=complaint, - department=department, - defaults={ - "role": "secondary", - "added_by": user, - "forwarded_at": timezone.now(), - } - ) + if not department.champion and not department.manager: + return JsonResponse({ + "success": False, + "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")), + }, status=400) - if not created: - involved_dept.forwarded_at = timezone.now() - involved_dept.save() + contact_person_id = request.POST.get("contact_person_id") + if not contact_person_id: + return JsonResponse({ + "success": False, + "error": str(_("Please select a contact person.")), + }, status=400) - # Send notification to department champion - if department.respondent and department.respondent.user and department.respondent.user.email: + contact_info = department.is_valid_contact_person(contact_person_id) + if not contact_info: + return JsonResponse({ + "success": False, + "error": str(_("Selected person is not a role holder in this department.")), + }, status=400) + + contact_person = contact_info["staff"] + + now = timezone.now() + + if complaint.department_id == department.pk: + complaint.sent_to_department = True + complaint.sent_to_department_at = now + if not complaint.forwarded_to_dept_at: + complaint.forwarded_to_dept_at = now + else: + involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create( + complaint=complaint, + department=department, + defaults={ + "role": "secondary", + "added_by": user, + "forwarded_at": now, + "sent": True, + "sent_at": now, + } + ) + + if not created: + involved_dept.forwarded_at = now + involved_dept.sent = True + involved_dept.sent_at = now + involved_dept.save() + + complaint.sent_to_department = True + complaint.sent_to_department_at = complaint.sent_to_department_at or now + + complaint.explanation_requested = True + complaint.explanation_requested_at = complaint.explanation_requested_at or now + + contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) + if complaint.department_id == department.pk and contact_email: NotificationService.send_email( - email=department.respondent.user.email, + email=contact_email, subject=f"Complaint Sent to Department - {complaint.reference_number}", message=f"Complaint #{complaint.reference_number} has been sent to your department ({department.name}).", html_message=f""" -

Complaint #{complaint.reference_number} has been sent to your department ({department.name}).

-

Title: {complaint.title or 'N/A'}

- {f'

Note: {note}

' if note else ''} -

View Department Page

+
+ {get_email_header_html()} +
+

Complaint Sent to Department

+

Complaint #{complaint.reference_number} has been sent to your department ({department.name}).

+

Title: {complaint.title or 'N/A'}

+ {f'

Note: {note}

' if note else ''} +

Assigned to: {contact_person.get_full_name()} ({contact_info['role_label']})

+

View Department Page

+
+
""", related_object=complaint, ) - message = f"Complaint sent to {department.name}." + message = f"Complaint sent to {department.name} — {contact_person.get_full_name()} ({contact_info['role_label']})." - # Change status to contacted if active + # Set forwarded timestamp and update tracking fields if active + now = timezone.now() if complaint.is_active_status and complaint.status in ("open", "in_progress"): - old_status = complaint.status - complaint.status = "contacted" - complaint.save(update_fields=["status"]) + if not complaint.forwarded_to_dept_at: + complaint.forwarded_to_dept_at = now + complaint.save(update_fields=[ + "forwarded_to_dept_at", + "sent_to_department", "sent_to_department_at", + "explanation_requested", "explanation_requested_at", + ]) ComplaintUpdate.objects.create( complaint=complaint, update_type="status_change", - message=f"Status changed from {old_status} to contacted - sent to {recipient_type}", + message=f"Complaint sent to {recipient_type}", created_by=user, ) + else: + if not complaint.forwarded_to_dept_at: + complaint.forwarded_to_dept_at = now + complaint.save(update_fields=[ + "forwarded_to_dept_at", + "sent_to_department", "sent_to_department_at", + "explanation_requested", "explanation_requested_at", + ]) return JsonResponse({ "success": True, @@ -1054,6 +1181,46 @@ def update_satisfaction(request, pk): return redirect("complaints:complaint_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def update_patient_contact_status(request, pk): + """Update patient contact status (contacted or contacted no response).""" + complaint = get_object_or_404(Complaint, pk=pk) + + if not can_manage_complaint(request.user, complaint): + messages.error(request, _("You don't have permission to update patient contact status.")) + return redirect("complaints:complaint_detail", pk=pk) + + patient_contact_status = request.POST.get("patient_contact_status", "") + valid_choices = ["not_contacted", "contacted", "contacted_no_response"] + if patient_contact_status and patient_contact_status not in valid_choices: + messages.error(request, _("Invalid patient contact status.")) + return redirect("complaints:complaint_detail", pk=pk) + + from django.utils import timezone + + old_status = complaint.patient_contact_status + complaint.patient_contact_status = patient_contact_status + if patient_contact_status and patient_contact_status != "not_contacted": + complaint.patient_contact_status_at = timezone.now() + complaint.patient_contact_status_by = request.user + else: + complaint.patient_contact_status_at = None + complaint.patient_contact_status_by = None + complaint.save(update_fields=["patient_contact_status", "patient_contact_status_at", "patient_contact_status_by", "updated_at"]) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="communication", + message=f"Patient contact status changed from {old_status} to {patient_contact_status}", + created_by=request.user, + ) + + messages.success(request, _("Patient contact status updated to: {}").format(complaint.get_patient_contact_status_display())) + + return redirect("complaints:complaint_detail", pk=pk) + + @login_required @require_http_methods(["POST"]) def toggle_escalated_ovr(request, pk): @@ -1091,7 +1258,7 @@ def _send_ovr_request_notification(complaint, requested_by): """Send email notification to admins when OVR escalation is requested""" from django.conf import settings from apps.accounts.models import User - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html try: admin_users = User.objects.filter( @@ -1119,6 +1286,24 @@ URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/ email=admin.email, subject=subject, message=message, + html_message=f""" +
+ {get_email_header_html()} +
+

OVR Escalation Request

+

A new OVR escalation request requires your approval.

+ + + + + +
Complaint:#{complaint.reference_number}
Title:{complaint.title}
Requested by:{requested_by.get_full_name()}
Hospital:{complaint.hospital.name}
+ +
+
+""", ) except Exception as e: import logging @@ -1186,7 +1371,7 @@ def _send_ovr_decision_notification(complaint, decided_by, decision): """Send email notification to requester when OVR is approved or rejected""" from django.conf import settings from apps.accounts.models import User - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html try: requested_by_pk = complaint.metadata.get("ovr_requested_by") @@ -1214,6 +1399,23 @@ URL: {settings.SITE_URL.rstrip('/')}/complaints/{complaint.pk}/ email=requested_by_user.email, subject=subject, message=message, + html_message=f""" +
+ {get_email_header_html()} +
+

OVR Escalation {decision_text.title()}

+

Your OVR escalation request for complaint #{complaint.reference_number} has been {decision_text}.

+ + + +
Title:{complaint.title}
Decision:{decision_text.title()} by {decided_by.get_full_name()}
+

{"The complaint is now escalated as OVR." if decision == "approved" else "Please contact the admin for more information."}

+ +
+
+""", ) except Exception as e: import logging @@ -1437,32 +1639,26 @@ def complaint_escalate(request, pk): reason = request.POST.get("reason", "") escalate_to_id = request.POST.get("escalate_to", "") - # Get the escalation target staff escalate_to_staff = None escalate_to_user = None + escalate_to_email = None if escalate_to_id: try: escalate_to_staff = Staff.objects.get(id=escalate_to_id) if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user + escalate_to_email = escalate_to_staff.user.email + elif escalate_to_staff.email: + escalate_to_email = escalate_to_staff.email except Staff.DoesNotExist: pass - # If no staff selected or not found, default to staff's manager - if not escalate_to_staff and complaint.staff and complaint.staff.report_to: - escalate_to_staff = complaint.staff.report_to - if escalate_to_staff.user and escalate_to_staff.user.is_active: - escalate_to_user = escalate_to_staff.user + if not escalate_to_staff: + messages.error(request, _("Please select a valid person to escalate to.")) + return redirect("complaints:complaint_detail", pk=pk) - # Fallback: use escalation target resolver if still no target - if not escalate_to_user: - from apps.complaints.services.complaint_service import ComplaintService - - fallback_user, fallback_path = ComplaintService.get_escalation_target(complaint, staff=complaint.staff) - if fallback_user: - escalate_to_user = fallback_user - reason += f" [fallback via {fallback_path}]" + escalate_to_name = escalate_to_staff.get_full_name() # Mark as escalated and assign to selected user complaint.escalated_at = timezone.now() @@ -1472,8 +1668,7 @@ def complaint_escalate(request, pk): # Create update with escalation details escalation_message = f"Complaint escalated. Reason: {reason}" - if escalate_to_user: - escalation_message += f" Escalated to: {escalate_to_user.get_full_name()}" + escalation_message += f" Escalated to: {escalate_to_name}" ComplaintUpdate.objects.create( complaint=complaint, @@ -1482,55 +1677,60 @@ def complaint_escalate(request, pk): created_by=request.user, metadata={ "reason": reason, + "escalated_to_staff_id": str(escalate_to_staff.id), "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, - "escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None, + "escalated_to_user_name": escalate_to_name, }, ) # Log audit AuditService.log_event( event_type="escalation", - description=f"Complaint escalated to {escalate_to_user.get_full_name() if escalate_to_user else 'manager'}", + description=f"Complaint escalated to {escalate_to_name}", user=request.user, content_object=complaint, metadata={ "reason": reason, + "escalated_to_staff_id": str(escalate_to_staff.id), "escalated_to_user_id": str(escalate_to_user.id) if escalate_to_user else None, - "escalated_to_user_name": escalate_to_user.get_full_name() if escalate_to_user else None, + "escalated_to_user_name": escalate_to_name, }, ) - # Send notification to the escalated user - if escalate_to_user and escalate_to_user.email: - from apps.notifications.services import NotificationService + # Send notification email + if escalate_to_email: + from apps.notifications.services import NotificationService, get_email_header_html + + email_subject = request.POST.get("email_subject", f"Complaint Escalated - {complaint.reference_number}") + email_body = request.POST.get("email_body", "") + + department_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/" if complaint.department else "" + html_message = f""" +
+ {get_email_header_html()} +
+

Complaint Escalated

+

Dear Manager,

+

This complaint has been escalated and requires your immediate attention.

+

Reference: {complaint.reference_number}
+ Title: {complaint.title or 'N/A'}
+ Severity: {complaint.get_severity_display()}
+ Priority: {complaint.get_priority_display()}
+ Status: {complaint.get_status_display()}

+

Please review and take appropriate action.

+

+ View Department +

+
+
+ """ try: NotificationService.send_email( - email=escalate_to_user.email, - subject=f"Complaint Escalated - #{complaint.id}", - message=f""" -Dear {escalate_to_user.get_full_name()}, - -A complaint has been escalated to you for attention. - -COMPLAINT DETAILS: ------------------- -Reference: #{complaint.id} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} -Priority: {complaint.get_priority_display()} - -ESCALATION REASON: ------------------- -{reason} - -Please review and take necessary action. - -View complaint: https://{request.get_host()}/complaints/{complaint.id}/ - ---- -This is an automated message from PX360 Complaint Management System. -""", + email=escalate_to_email, + subject=email_subject, + message=email_body, + html_message=html_message, related_object=complaint, metadata={ "notification_type": "complaint_escalated", @@ -1543,7 +1743,7 @@ This is an automated message from PX360 Complaint Management System. messages.success( request, - f"Complaint escalated successfully{f' to {escalate_to_user.get_full_name()}' if escalate_to_user else ''}.", + f"Complaint escalated successfully to {escalate_to_name}.", ) return redirect("complaints:complaint_detail", pk=pk) @@ -1795,8 +1995,6 @@ def complaint_export_monthly_calculations(request): queryset = queryset.select_related( "hospital", "department", - "main_section", - "subsection", "assigned_to", "resolved_by", "closed_by", @@ -2055,7 +2253,7 @@ def inquiry_list(request): context = { "page_obj": page_obj, - "inquiries": page_obj.object_list, + "inquiries": page_obj, "stats": stats, "departments": departments, "filters": request.GET, @@ -2082,7 +2280,7 @@ def inquiry_detail(request, pk): inquiry = get_object_or_404( Inquiry.objects.select_related( - "patient", "hospital", "department", "location", "main_section", "subsection", + "patient", "hospital", "department", "assigned_to", "responded_by", "outgoing_department", "department_responded_by", "taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification", @@ -2146,6 +2344,53 @@ def inquiry_detail(request, pk): "assigned_to", "created_by" ) + escalation_targets = [] + + if inquiry.hospital: + from django.db.models import Q + + escalation_targets_qs = ( + Staff.objects.filter( + hospital=inquiry.hospital, status="active", user__isnull=False, user__is_active=True + ) + .filter(Q(user__groups__name__in=["Hospital Admin", "Department Manager"]) | Q(direct_reports__isnull=False)) + .select_related("user", "department", "report_to") + .distinct() + .order_by("first_name", "last_name") + ) + + for staff in escalation_targets_qs: + escalation_targets.append( + { + "staff": staff, + "has_user": True, + "user_id": str(staff.user.id), + "is_manager": staff.direct_reports.exists(), + "is_line_manager": False, + "group": "managers", + } + ) + + escalation_targets.sort(key=lambda x: (not x["is_manager"], x["staff"].get_full_name())) + + if inquiry.department: + dept = Department.objects.filter(pk=inquiry.department_id).first() + if dept: + dept_role_holders = [] + for holder in dept.get_role_holders(): + if holder["staff"].user and holder["staff"].user.is_active: + dept_role_holders.append({ + "staff": holder["staff"], + "has_user": True, + "user_id": str(holder["staff"].user.id), + "is_manager": False, + "is_line_manager": False, + "role_label": holder["role_label"], + "group": "department_roles", + }) + if dept_role_holders: + escalation_targets = dept_role_holders + escalation_targets + context = { "inquiry": inquiry, "timeline": timeline, @@ -2170,8 +2415,28 @@ def inquiry_detail(request, pk): "base_layout": base_layout, "source_user": source_user, "linked_rcas": linked_rcas, + "escalation_targets": escalation_targets, + "escalation_email_subject": f"Inquiry Escalated - {inquiry.reference_number} - {inquiry.subject or 'N/A'}", + "escalation_email_body": ( + f"Dear Manager,\n\n" + f"This inquiry has been escalated and requires your immediate attention.\n\n" + f"Reference: {inquiry.reference_number}\n" + f"Subject: {inquiry.subject or 'N/A'}\n" + f"Priority: {inquiry.get_priority_display()}\n" + f"Status: {inquiry.get_status_display()}\n" + f"Department: {inquiry.department.get_localized_name() if inquiry.department else 'N/A'}\n\n" + f"Please review and take appropriate action.\n\n" + f"View: https://{request.get_host()}/inquiries/{inquiry.pk}/" + ), } + from django.contrib.contenttypes.models import ContentType + inquiry_ct = ContentType.objects.get_for_model(inquiry) + context["content_type_id"] = inquiry_ct.pk + context["object_id"] = inquiry.pk + context["notes"] = inquiry.notes.select_related("created_by").all() + context["notes_count"] = context["notes"].count() + return render(request, "complaints/inquiry_detail.html", context) @@ -2220,19 +2485,31 @@ def inquiry_send_to_staff(request, pk): explanation_url = f"https://{site.domain}/inquiries/{inquiry.id}/explain/{token}/" if staff.email: + from django.template.loader import render_to_string + + email_context = { + "staff_name": f"{staff.first_name} {staff.last_name}", + "inquiry_subject": inquiry.subject, + "inquiry_reference": inquiry.reference_number or str(inquiry.id), + "inquiry_message": inquiry.message[:500], + "request_message": request_message, + "explanation_url": explanation_url, + "site_name": site.name, + } + html_message = render_to_string("emails/inquiry_explanation_request.html", email_context) + message = ( + f"Dear {staff.first_name} {staff.last_name},\n\n" + f"You have been requested to provide a response regarding inquiry #{inquiry.reference_number or inquiry.id}.\n\n" + f"Subject: {inquiry.subject}\n\n" + f"Please submit your response using the link below:\n{explanation_url}\n" + ) NotificationService.send_email( - to_email=staff.email, + email=staff.email, subject=f"Inquiry Response Requested - #{inquiry.reference_number or inquiry.id}", - template_name="emails/inquiry_explanation_request", - context={ - "staff_name": f"{staff.first_name} {staff.last_name}", - "inquiry_subject": inquiry.subject, - "inquiry_reference": inquiry.reference_number or str(inquiry.id), - "inquiry_message": inquiry.message[:500], - "request_message": request_message, - "explanation_url": explanation_url, - "site_name": site.name, - }, + message=message, + html_message=html_message, + related_object=explanation, + metadata={"notification_type": "inquiry_explanation_request"}, ) messages.success(request, _(f"Response request sent to {staff.first_name} {staff.last_name}")) @@ -2250,6 +2527,7 @@ def inquiry_create(request): # Determine base layout based on user type source_user = SourceUser.objects.filter(user=request.user).first() base_layout = "layouts/source_user_base.html" if source_user else "layouts/base.html" + communication_request = None if request.method == "POST": form = InquiryForm(request.POST, request=request) @@ -2330,7 +2608,6 @@ def inquiry_create(request): if hospital_id: initial_data["hospital"] = hospital_id - communication_request = None comm_req_id = request.GET.get("comm_req") if comm_req_id: try: @@ -2419,12 +2696,6 @@ def inquiry_edit(request, pk): messages.error(request, f"Please correct the errors: {form.errors}") else: initial_data = {} - if inquiry.location: - initial_data["location"] = inquiry.location_id - if inquiry.main_section: - initial_data["main_section"] = inquiry.main_section_id - if inquiry.subsection: - initial_data["subsection"] = inquiry.subsection_id form = InquiryForm(request=request, instance=inquiry, initial=initial_data) context = { @@ -2464,8 +2735,10 @@ def inquiry_activate(request, pk): # Only change status to in_progress if it's currently open if inquiry.status == "open": inquiry.status = "in_progress" - - inquiry.save(update_fields=["assigned_to", "assigned_at", "status"]) + inquiry.activated_at = timezone.now() + inquiry.save(update_fields=["assigned_to", "assigned_at", "status", "activated_at"]) + else: + inquiry.save(update_fields=["assigned_to", "assigned_at"]) # Create update roles_display = ", ".join(user.get_role_names()) @@ -2767,33 +3040,49 @@ def inquiry_respond(request, pk): inquiry.save(update_fields=["response_sent_at"]) try: - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html + from apps.core.utils import build_public_track_url + + track_url = build_public_track_url("inquiry", inquiry.reference_number) if inquiry.contact_phone: - sms_text = response_en if response_en else response_ar - if len(sms_text) > 200: - sms_text = sms_text[:197] + "..." NotificationService.send_sms( phone=inquiry.contact_phone, - message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. {sms_text}", + message=f"PX360: Your inquiry #{inquiry.reference_number} has been responded to. View details: {track_url}", related_object=inquiry, metadata={"notification_type": "inquiry_response_sent"}, ) if inquiry.contact_email: email_subject = f"PX360: Response to Your Inquiry #{inquiry.reference_number}" - email_body_parts = [] - if response_en: - email_body_parts.append(f"Response (English):\n\n{response_en}") - if response_ar: - email_body_parts.append(f"الرد (العربية):\n\n{response_ar}") - email_body = "\n\n---\n\n".join(email_body_parts) - email_body += f"\n\n---\nReference: {inquiry.reference_number}\nHospital: {inquiry.hospital.name}\n\nThis is an automated message from PX 360." - + email_body = ( + f"Dear Valued Patient,\n\n" + f"Your inquiry #{inquiry.reference_number} has been responded to.\n\n" + f"To view the full response, please visit:\n{track_url}\n\n" + f"Thank you for your patience.\n\n" + f"Reference: {inquiry.reference_number}\n" + f"This is an automated message from PX 360." + ) NotificationService.send_email( email=inquiry.contact_email, subject=email_subject, message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Response to Your Inquiry

+

Dear Valued Patient,

+

Your inquiry #{inquiry.reference_number} has been responded to.

+

To view the full response, please click the link below:

+ +

Reference: {inquiry.reference_number}

+

Thank you for your patience.

+
+
+""", related_object=inquiry, metadata={"notification_type": "inquiry_response_email"}, ) @@ -2834,11 +3123,27 @@ def inquiry_transfer_to_department(request, pk): from apps.organizations.models import Department try: - department = Department.objects.get(pk=department_id, status="active") + department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: messages.error(request, _("Department not found.")) return redirect("inquiries:inquiry_detail", pk=pk) + if not department.champion and not department.manager: + messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + contact_person_id = request.POST.get("contact_person_id") + if not contact_person_id: + messages.error(request, _("Please select a contact person.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + contact_info = department.is_valid_contact_person(contact_person_id) + if not contact_info: + messages.error(request, _("Selected person is not a role holder in this department.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + contact_person = contact_info["staff"] + recipient_type = request.POST.get("recipient_type", "staff") note_en = request.POST.get("note_en", "").strip() @@ -2867,7 +3172,9 @@ def inquiry_transfer_to_department(request, pk): inquiry.dept_response_escalated_at = None if inquiry.status in ("open",): - inquiry.status = "contacted" + inquiry.contact_status = "contacted" + inquiry.contact_status_at = timezone.now() + inquiry.contact_status_by = user inquiry.save() InquiryUpdate.objects.create( @@ -2878,19 +3185,50 @@ def inquiry_transfer_to_department(request, pk): ) try: + import secrets from apps.notifications.settings_service import NotificationServiceWithSettings + # Generate token for response link + response_token = secrets.token_urlsafe(32) + inquiry.response_token = response_token + inquiry.response_token_sent_at = timezone.now() + inquiry.save(update_fields=["response_token", "response_token_sent_at"]) + + # Build response link + from django.contrib.sites.shortcuts import get_current_site + current_site = get_current_site(request) + domain = current_site.domain if current_site else request.get_host() + response_link = f"https://{domain}/inquiries/{inquiry.pk}/respond/{response_token}/" + NotificationServiceWithSettings.send_inquiry_department_assigned( department, inquiry, context_note_en=note_en, context_note_ar=note_ar, recipient_type=recipient_type, ) - if department.respondent and department.respondent.user and department.respondent.user.email: - from apps.notifications.services import NotificationService + contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) + if contact_email: + from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( - email=department.respondent.user.email, + email=contact_email, subject=f"Inquiry #{inquiry.reference_number} - Response Required", - message=f"An inquiry has been transferred to your department ({department.get_localized_name()}) for response. Subject: {inquiry.subject}. Please submit your response before the deadline: {inquiry.dept_response_sla_due_at}", + message=f"An inquiry has been transferred to your department ({department.get_localized_name()}) for response.\n\nSubject: {inquiry.subject}\n\nSubmit your response here: {response_link}\n\nDeadline: {inquiry.dept_response_sla_due_at}", + html_message=f""" +
+ {get_email_header_html()} +
+

Inquiry #{inquiry.reference_number} - Response Required

+

An inquiry has been transferred to your department ({department.get_localized_name()}) for response.

+ + + +
Subject:{inquiry.subject}
Deadline:{inquiry.dept_response_sla_due_at}
+ +

This link can only be used once. After submission, it will expire.

+
+
+""", related_object=inquiry, ) except Exception as e: @@ -2903,6 +3241,96 @@ def inquiry_transfer_to_department(request, pk): return redirect("inquiries:inquiry_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def inquiry_escalate(request, pk): + from .models import Inquiry, InquiryUpdate + from apps.organizations.models import Staff + from apps.notifications.services import NotificationService, get_email_header_html + + inquiry = get_object_or_404(Inquiry, pk=pk) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + ): + messages.error(request, _("You don't have permission to escalate inquiries.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + if inquiry.status in ("closed", "cancelled"): + messages.error(request, _("Cannot escalate a closed or cancelled inquiry.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + escalate_to_id = request.POST.get("escalate_to", "") + reason = request.POST.get("reason", "") + + escalate_to_staff = None + escalate_to_user = None + + if escalate_to_id: + try: + escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active") + if escalate_to_staff.user and escalate_to_staff.user.is_active: + escalate_to_user = escalate_to_staff.user + except Staff.DoesNotExist: + pass + + if not escalate_to_user: + messages.error(request, _("Please select a valid person to escalate to.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + inquiry.escalated_at = timezone.now() + inquiry.save(update_fields=["escalated_at"]) + + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message=f"Inquiry escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", + created_by=user, + ) + + email_subject = request.POST.get("email_subject", f"Inquiry Escalated - {inquiry.reference_number}") + email_body = request.POST.get("email_body", "") + + if escalate_to_user.email: + try: + NotificationService.send_email( + email=escalate_to_user.email, + subject=email_subject, + message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Inquiry Escalated - {inquiry.reference_number}

+

An inquiry has been escalated to you.

+ + + + +
Reference:{inquiry.reference_number}
Escalated by:{user.get_full_name()}
Reason:{reason or 'N/A'}
+
+

{email_body}

+
+
+
+""", + related_object=inquiry, + metadata={ + "notification_type": "inquiry_escalated", + "escalated_by": str(user.id), + "reason": reason, + }, + ) + except Exception as e: + logger.error(f"Failed to send inquiry escalation email: {e}") + + messages.success(request, _(f"Inquiry escalated to {escalate_to_staff.get_full_name()}.")) + return redirect("inquiries:inquiry_detail", pk=pk) + + @login_required @require_http_methods(["POST"]) def inquiry_send_to(request, pk): @@ -2910,7 +3338,7 @@ def inquiry_send_to(request, pk): Unified AJAX endpoint to send inquiry to either a person or department. """ from django.http import JsonResponse - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user @@ -2959,10 +3387,16 @@ def inquiry_send_to(request, pk): subject=f"Inquiry Assigned - {inquiry.reference_number}", message=f"You have been assigned to inquiry #{inquiry.reference_number}.", html_message=f""" -

You have been assigned to inquiry #{inquiry.reference_number}.

-

Subject: {inquiry.subject or 'N/A'}

- {f'

Note: {note}

' if note else ''} -

View Inquiry

+
+ {get_email_header_html()} +
+

Inquiry Assigned

+

You have been assigned to inquiry #{inquiry.reference_number}.

+

Subject: {inquiry.subject or 'N/A'}

+ {f'

Note: {note}

' if note else ''} +

View Inquiry

+
+
""", related_object=inquiry, ) @@ -2978,13 +3412,33 @@ def inquiry_send_to(request, pk): }, status=400) try: - department = Department.objects.get(pk=department_id, status="active") + department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) + if not department.champion and not department.manager: + return JsonResponse({ + "success": False, + "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")), + }, status=400) + + contact_person_id = request.POST.get("contact_person_id") + if not contact_person_id: + return JsonResponse({ + "success": False, + "error": str(_("Please select a contact person.")), + }, status=400) + + contact_info = department.is_valid_contact_person(contact_person_id) + if not contact_info: + return JsonResponse({ + "success": False, + "error": str(_("Selected person is not a role holder in this department.")), + }, status=400) + # Transfer to department inquiry.outgoing_department = department inquiry.transferred_at = timezone.now() @@ -2992,6 +3446,10 @@ def inquiry_send_to(request, pk): inquiry.transferred_to_department = department inquiry.transfer_count = (inquiry.transfer_count or 0) + 1 + if inquiry.department_id == department.pk: + inquiry.sent_to_department = True + inquiry.sent_to_department_at = timezone.now() + sla_config = inquiry.get_sla_config() if sla_config and sla_config.dept_response_hours: from datetime import timedelta @@ -3001,11 +3459,13 @@ def inquiry_send_to(request, pk): inquiry.dept_response_second_reminder_sent_at = None inquiry.dept_response_escalated_at = None - message = f"Inquiry sent to {department.get_localized_name()}." + message = f"Inquiry sent to {department.get_localized_name()} — {contact_info['name']} ({contact_info['role_label']})." - # Change status to contacted if open + # Set contact_status if open if inquiry.status in ("open",): - inquiry.status = "contacted" + inquiry.contact_status = "contacted" + inquiry.contact_status_at = timezone.now() + inquiry.contact_status_by = user inquiry.save() @@ -3117,7 +3577,35 @@ Generate a JSON response with: content_object=inquiry, ) + # Notify inquirer that department has responded + try: + from apps.notifications.services import NotificationService + from apps.core.utils import build_public_track_url + + track_url = build_public_track_url("inquiry", inquiry.reference_number) + if inquiry.contact_phone: + NotificationService.send_sms( + phone=inquiry.contact_phone, + message=f"PX360: Your inquiry {inquiry.reference_number} has been responded to. View: {track_url}", + related_object=inquiry, + ) + if inquiry.contact_email: + NotificationService.send_email( + email=inquiry.contact_email, + subject=f"PX360: Response to Your Inquiry {inquiry.reference_number}", + message=f"Your inquiry has been responded to.\n\nView: {track_url}", + related_object=inquiry, + ) + except Exception as e: + logger.warning(f"Failed to send inquirer notification: {e}") + messages.success(request, "Department response submitted successfully.") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + from django.http import JsonResponse + redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("inquiries:inquiry_detail", kwargs={"pk": pk}) + return JsonResponse({"success": True, "redirect_url": redirect_url}) + if user.department: + return redirect("organizations:department_detail", pk=user.department.pk) return redirect("inquiries:inquiry_detail", pk=pk) context = { @@ -3149,34 +3637,109 @@ def inquiry_review_dept_response(request, pk): notes = request.POST.get("acceptance_notes", "").strip() - inquiry.dept_response_acceptance_status = status - inquiry.dept_response_accepted_by = user - inquiry.dept_response_accepted_at = timezone.now() - inquiry.dept_response_acceptance_notes = notes - inquiry.save( - update_fields=[ - "dept_response_acceptance_status", - "dept_response_accepted_by", - "dept_response_accepted_at", - "dept_response_acceptance_notes", - ] - ) + if status == "not_acceptable": + inquiry.dept_response_acceptance_status = "not_acceptable" + inquiry.dept_response_accepted_by = user + inquiry.dept_response_accepted_at = timezone.now() + inquiry.dept_response_acceptance_notes = notes + inquiry.department_response_en = "" + inquiry.department_response_ar = "" + inquiry.department_response_summary_en = "" + inquiry.department_response_summary_ar = "" + inquiry.department_responded_at = None + inquiry.department_responded_by = None + inquiry.save( + update_fields=[ + "dept_response_acceptance_status", + "dept_response_accepted_by", + "dept_response_accepted_at", + "dept_response_acceptance_notes", + "department_response_en", + "department_response_ar", + "department_response_summary_en", + "department_response_summary_ar", + "department_responded_at", + "department_responded_by", + ] + ) - InquiryUpdate.objects.create( - inquiry=inquiry, - update_type="note", - message=f"Department response marked as {status} by {user.get_full_name()}. {notes}", - created_by=user, - ) + dept = inquiry.outgoing_department or inquiry.transferred_to_department + if dept and dept.champion and dept.champion.user and dept.champion.user.email: + try: + from apps.notifications.services import NotificationService, get_email_header_html - AuditService.log_event( - event_type="inquiry_dept_response_review", - description=f"Department response for inquiry {inquiry.reference_number} marked as {status}", - user=user, - content_object=inquiry, - ) + NotificationService.send_email( + email=dept.champion.user.email, + subject=f"Action Required: Inquiry #{inquiry.reference_number} - Response Rejected", + message=( + f"Your department's response for inquiry #{inquiry.reference_number} has been rejected.\n\n" + f"Reason: {notes}\n\n" + f"Please revise and resubmit your response." + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Response Rejected - Inquiry #{inquiry.reference_number}

+

Your department's response for inquiry #{inquiry.reference_number} has been rejected.

+
+

Reason: {notes}

+
+

Please revise and resubmit your response.

+
+
+""", + related_object=inquiry, + ) + except Exception: + import logging + logging.getLogger(__name__).exception("Failed to send inquiry dept response rejection email") + + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message=f"Department response rejected by {user.get_full_name()}. Reason: {notes}", + created_by=user, + ) + + AuditService.log_event( + event_type="inquiry_dept_response_review", + description=f"Department response for inquiry {inquiry.reference_number} rejected by {user.get_full_name()}", + user=user, + content_object=inquiry, + ) + + messages.success(request, "Department response rejected. The department has been notified to resubmit.") + else: + inquiry.dept_response_acceptance_status = status + inquiry.dept_response_accepted_by = user + inquiry.dept_response_accepted_at = timezone.now() + inquiry.dept_response_acceptance_notes = notes + inquiry.save( + update_fields=[ + "dept_response_acceptance_status", + "dept_response_accepted_by", + "dept_response_accepted_at", + "dept_response_acceptance_notes", + ] + ) + + InquiryUpdate.objects.create( + inquiry=inquiry, + update_type="note", + message=f"Department response marked as {status} by {user.get_full_name()}. {notes}", + created_by=user, + ) + + AuditService.log_event( + event_type="inquiry_dept_response_review", + description=f"Department response for inquiry {inquiry.reference_number} marked as {status}", + user=user, + content_object=inquiry, + ) + + messages.success(request, f"Department response marked as {status}.") - messages.success(request, f"Department response marked as {status}.") return redirect("inquiries:inquiry_detail", pk=pk) @@ -3204,17 +3767,27 @@ def inquiry_send_dept_response_reminder(request, pk): reminder_type = request.POST.get("reminder_type", "first") try: - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html 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) for recipient in recipients: NotificationService.send_email( email=recipient.email, subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required", message=f"This is a reminder that inquiry #{inquiry.reference_number} is awaiting your department's response. Please submit your response as soon as possible.", + html_message=f""" +
+ {get_email_header_html()} +
+

Reminder: Response Required

+

This is a reminder that inquiry #{inquiry.reference_number} is awaiting your department's response.

+

Please submit your response as soon as possible.

+
+
+""", related_object=inquiry, ) @@ -3309,7 +3882,9 @@ def inquiry_update_contact_stage(request, pk): ) inquiry.contacted_nr_by = user if inquiry.status == "open": - inquiry.status = "contacted_no_response" + inquiry.contact_status = "contacted_no_response" + inquiry.contact_status_at = timezone.now() + inquiry.contact_status_by = user elif stage == "under_process": date_str = request.POST.get("under_process_date") @@ -3326,7 +3901,7 @@ def inquiry_update_contact_stage(request, pk): seconds=int(parts[2] if len(parts) > 2 else 0), ) inquiry.under_process_by = user - if inquiry.status in ("open", "contacted_no_response"): + if inquiry.status == "open": inquiry.status = "in_progress" elif stage == "contacted": @@ -3344,8 +3919,10 @@ def inquiry_update_contact_stage(request, pk): seconds=int(parts[2] if len(parts) > 2 else 0), ) inquiry.contacted_by = user - if inquiry.status in ("open", "in_progress", "contacted_no_response"): - inquiry.status = "contacted" + if inquiry.status in ("open", "in_progress"): + inquiry.contact_status = "contacted" + inquiry.contact_status_at = timezone.now() + inquiry.contact_status_by = user elif stage == "notes": inquiry.staff_notes = request.POST.get("staff_notes", inquiry.staff_notes) @@ -3435,19 +4012,21 @@ def public_complaint_submit(request): try: # Get form data from public complaint form complainant_name = request.POST.get("complainant_name") - email = request.POST.get("email") + email = request.POST.get("email", "") mobile_number = request.POST.get("mobile_number") - relation_to_patient = request.POST.get("relation_to_patient") + relation_to_patient = request.POST.get("relation_to_patient", "") hospital_id = request.POST.get("hospital") - location_id = request.POST.get("location") - main_section_id = request.POST.get("main_section") - subsection_id = request.POST.get("subsection") - patient_name = request.POST.get("patient_name") - national_id = request.POST.get("national_id") + category = request.POST.get("category", "") + location_type = request.POST.get("location_type", "") + department_id = request.POST.get("department") + section_id = request.POST.get("section") + area_id = request.POST.get("area") + patient_name = request.POST.get("patient_name", "") + national_id = request.POST.get("national_id", "") incident_date = request.POST.get("incident_date") - staff_name = request.POST.get("staff_name") + staff_name = request.POST.get("staff_name", "") complaint_details = request.POST.get("complaint_details") - expected_result = request.POST.get("expected_result") + expected_result = request.POST.get("expected_result", "") # Validate required fields errors = [] @@ -3457,10 +4036,10 @@ def public_complaint_submit(request): errors.append(_("Mobile number is required")) if not hospital_id: errors.append(_("Hospital is required")) - if not location_id: - errors.append(_("Location is required")) - if not main_section_id: - errors.append(_("Main section is required")) + if not location_type: + errors.append(_("Location type is required")) + if not department_id: + errors.append(_("Department is required")) if not complaint_details: errors.append(_("Complaint details are required")) if incident_date: @@ -3488,52 +4067,37 @@ def public_complaint_submit(request): # Get hospital hospital = Hospital.objects.get(id=hospital_id) - # Get location hierarchy objects - from apps.organizations.models import Location, MainSection, SubSection + from apps.organizations.models import Area, Department, Section, OrgSubSection - location = Location.objects.get(id=location_id) - main_section = MainSection.objects.get(id=main_section_id) - subsection = None - if subsection_id: - subsection = SubSection.objects.get(internal_id=subsection_id) + department = Department.objects.get(id=department_id) + 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 - # Generate unique reference number: CMP-YYYYMMDD-XXXXX - import uuid - from datetime import datetime - - today = datetime.now().strftime("%Y%m%d") - random_suffix = str(uuid.uuid4().int)[:6] - reference_number = f"CMP-{today}-{random_suffix}" + # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) # Create complaint with location hierarchy and all form fields complaint = Complaint.objects.create( - patient=None, # No patient record for public submissions + patient=None, hospital=hospital, - department=None, # AI will determine this - title="Complaint", # AI will generate title + department=department, + section=section, + area=area, + location_type=location_type if location_type else "", + title="Complaint", description=complaint_details, - severity="medium", # Default, AI will update - priority="medium", # Default, AI will update - status="open", # Start as open - complaint_source_type=ComplaintSourceType.EXTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(), - reference_number=reference_number, - # Location hierarchy (FK relationships) - location=location, - main_section=main_section, - subsection=subsection, - # Complainant information + severity="medium", + priority="medium", + status="open", + complaint_source_type=ComplaintSourceType.INTERNAL, source=PXSource.objects.filter(name_en="Public Form").first(), contact_name=complainant_name, contact_phone=mobile_number, contact_email=email, - # Store additional information in metadata - metadata={ - "relation_to_patient": relation_to_patient, - "patient_name": patient_name, - "national_id": national_id, - "incident_date": incident_date, - "staff_name": staff_name, - "expected_result": expected_result, - }, + relation_to_patient=relation_to_patient, + patient_name=patient_name, + national_id=national_id, + incident_date=parsed_date if incident_date else None, + staff_name=staff_name, + expected_result=expected_result, ) # Create initial update @@ -3626,7 +4190,7 @@ def public_complaint_track(request): # Try to find complaint by reference number try: complaint = ( - Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection") + Complaint.objects.select_related("hospital", "department") .prefetch_related("updates") .get(reference_number__iexact=reference_number) ) @@ -3641,7 +4205,7 @@ def public_complaint_track(request): # GET request with reference parameter try: complaint = ( - Complaint.objects.select_related("hospital", "department", "location", "main_section", "subsection") + Complaint.objects.select_related("hospital", "department") .prefetch_related("updates") .get(reference_number__iexact=reference_number) ) @@ -3658,7 +4222,7 @@ def public_complaint_track(request): public_status = complaint.public_status public_updates = list( - complaint.updates.filter(update_type__in=["status_change", "resolution", "communication"]).order_by( + complaint.updates.filter(update_type__in=["status_change", "resolution"]).order_by( "-created_at" ) ) @@ -3667,16 +4231,14 @@ def public_complaint_track(request): "open": str(_("Received")), "in_progress": str(_("In Progress")), "partially_resolved": str(_("In Progress")), - "contacted": str(_("In Progress")), - "contacted_no_response": str(_("In Progress")), "resolved": str(_("Resolved")), "closed": str(_("Closed")), "cancelled": str(_("Cancelled")), } for update in public_updates: - if update.comments: + if update.message: for internal, public_label in _status_map.items(): - update.comments = update.comments.replace(internal, public_label) + update.message = update.message.replace(internal, public_label) if hasattr(update, "old_status") and update.old_status: update.old_status = _status_map.get(update.old_status, update.old_status) if hasattr(update, "new_status") and update.new_status: @@ -3711,6 +4273,9 @@ def public_inquiry_submit(request): email = request.POST.get("email") phone = request.POST.get("phone") hospital_id = request.POST.get("hospital") + location_type = request.POST.get("location_type", "") + department_id = request.POST.get("department", "") + section_id = request.POST.get("section", "") category = request.POST.get("category", "general") subject = request.POST.get("subject") message = request.POST.get("message") @@ -3749,6 +4314,10 @@ def public_inquiry_submit(request): hospital = Hospital.objects.get(id=hospital_id) + from apps.organizations.models import Department, Section + 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 + import uuid from datetime import datetime @@ -3759,6 +4328,9 @@ def public_inquiry_submit(request): inquiry = Inquiry.objects.create( patient=None, hospital=hospital, + department=department, + section=section, + location_type=location_type if location_type else "", subject=subject, message=message, category=category, @@ -3831,10 +4403,9 @@ def public_inquiry_track(request): Public inquiry tracking page. Allows users to check their inquiry status using the reference number received after submission. """ - from .models import Inquiry, InquiryUpdate + from .models import Inquiry inquiry = None - public_updates = [] public_status = None error_message = None reference = request.GET.get("reference", "").strip() or request.POST.get("reference", "").strip() @@ -3867,15 +4438,13 @@ def public_inquiry_track(request): "progress": sm["progress"], "css": sm["css"], } - - public_updates = list( - InquiryUpdate.objects.filter(inquiry=inquiry) - .select_related("created_by") - .order_by("-created_at")[:20] - ) else: error_message = _("No inquiry found with this reference number. Please check and try again.") + has_response = bool(inquiry and (inquiry.department_response_en or inquiry.department_response_ar)) + response_en = inquiry.department_response_en if inquiry else "" + response_ar = inquiry.department_response_ar if inquiry else "" + if request.headers.get("x-requested-with") == "XMLHttpRequest": if inquiry: return JsonResponse({ @@ -3884,14 +4453,9 @@ def public_inquiry_track(request): "status": inquiry.get_status_display(), "subject": inquiry.subject, "created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"), - "updates": [ - { - "type": u.update_type, - "message": u.message, - "date": u.created_at.strftime("%Y-%m-%d %H:%M"), - } - for u in public_updates - ], + "has_response": has_response, + "response_en": response_en, + "response_ar": response_ar, }) else: return JsonResponse({"success": False, "error": "Inquiry not found"}) @@ -3899,9 +4463,11 @@ def public_inquiry_track(request): return render(request, "complaints/public_inquiry_track.html", { "inquiry": inquiry, "public_status": public_status, - "public_updates": public_updates, "error_message": error_message, "reference_number": reference, + "has_response": has_response, + "response_en": response_en, + "response_ar": response_ar, }) @@ -4709,7 +5275,6 @@ def confirm_ai_department_suggestion(request, complaint_pk): is_primary=True, added_by=user, assigned_at=timezone.now(), - forwarded_at=timezone.now(), ) ComplaintUpdate.objects.create( @@ -4772,10 +5337,6 @@ def involved_department_add(request, complaint_pk): if involved_dept.assigned_to and not involved_dept.assigned_at: involved_dept.assigned_at = timezone.now() - # Mark as forwarded to department (for tracking pending responses) - if not involved_dept.forwarded_at: - involved_dept.forwarded_at = timezone.now() - involved_dept.save() # Log the update @@ -4956,8 +5517,10 @@ def involved_department_response(request, pk): return redirect("complaints:complaint_detail", pk=complaint.pk) response_notes = request.POST.get("response_notes", "").strip() + response_notes_en = request.POST.get("response_notes_en", "").strip() + response_notes_ar = request.POST.get("response_notes_ar", "").strip() - if not response_notes: + if not response_notes and not response_notes_en and not response_notes_ar: if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return JsonResponse({ "success": False, @@ -4966,12 +5529,20 @@ def involved_department_response(request, pk): messages.error(request, _("Please provide a valid response.")) return redirect("complaints:complaint_detail", pk=complaint.pk) - involved_dept.response_notes = response_notes + involved_dept.response_notes = response_notes or response_notes_en or response_notes_ar + involved_dept.response_notes_en = response_notes_en + involved_dept.response_notes_ar = response_notes_ar involved_dept.response_submitted = True involved_dept.response_submitted_at = timezone.now() + involved_dept.acceptance_status = "pending" + involved_dept.accepted_by = None + involved_dept.accepted_at = None + involved_dept.acceptance_notes = "" + involved_dept.manager_review_status = "pending" + involved_dept.manager_reviewed_by = None + involved_dept.manager_reviewed_at = None involved_dept.save() - # Log the update ComplaintUpdate.objects.create( complaint=complaint, update_type="note", @@ -4979,27 +5550,172 @@ def involved_department_response(request, pk): created_by=user, ) - # Send notification to complaint assigned_to - if complaint.assigned_to and complaint.assigned_to.email: + # Notify complainant that department has responded + try: from apps.notifications.services import NotificationService - NotificationService.send_email( - complaint.assigned_to.email, - subject=f"Department Response Received - {complaint.reference_number}", - message=f"A response has been submitted by {involved_dept.department.name} for complaint {complaint.reference_number}.", - html_message=f""" -

A response has been submitted by {involved_dept.department.name} - for complaint {complaint.reference_number}.

-

View Complaint

- """, - ) + from apps.core.utils import build_public_track_url + + track_url = build_public_track_url("complaint", complaint.reference_number) + if complaint.contact_phone: + NotificationService.send_sms( + phone=complaint.contact_phone, + message=f"PX360: Your complaint {complaint.reference_number} has been responded to. View: {track_url}", + related_object=complaint, + ) + if complaint.contact_email: + NotificationService.send_email( + email=complaint.contact_email, + subject=f"PX360: Response to Your Complaint {complaint.reference_number}", + message=f"Your complaint has been responded to.\n\nView: {track_url}", + related_object=complaint, + ) + except Exception as e: + logger.warning(f"Failed to send complainant notification: {e}") + + dept = involved_dept.department + if dept.manager and dept.manager.email: + try: + from apps.notifications.services import NotificationService, get_email_header_html + review_url = request.build_absolute_uri( + reverse("organizations:department_manager_review", kwargs={"pk": dept.pk, "idept_pk": involved_dept.pk}) + ) + NotificationService.send_email( + dept.manager.email, + subject=f"Champion Response Requires Your Review - {complaint.reference_number}", + message=( + f"A champion response for complaint {complaint.reference_number} " + f"from {involved_dept.department.name} requires your review and approval.\n\n" + f"Please review at: {review_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Champion Response Requires Your Review

+

A champion from {involved_dept.department.name} has submitted a response + for complaint {complaint.reference_number}.

+

Your review and approval is required before it is forwarded to the PX team.

+

+ Review Response +

+
+
+ """, + related_object=complaint, + ) + except Exception as e: + logger.error(f"Failed to send manager review notification: {e}") if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("complaints:complaint_detail", kwargs={"pk": complaint.pk}) return JsonResponse({ "success": True, "message": str(_("Department response submitted successfully.")), + "redirect_url": redirect_url, }) messages.success(request, _("Department response submitted successfully.")) + if user.department: + return redirect("organizations:department_detail", pk=user.department.pk) + return redirect("complaints:complaint_detail", pk=complaint.pk) + + +@login_required +@require_http_methods(["POST"]) +def involved_department_review_response(request, pk): + """ + Review (accept/reject) a department's response to a complaint. + On rejection, notifies the champion and resets for resubmission. + """ + from .models import ComplaintInvolvedDepartment + + involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk) + complaint = involved_dept.complaint + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to review department responses.")) + return redirect("complaints:complaint_detail", pk=complaint.pk) + + if not involved_dept.response_submitted: + messages.error(request, _("No department response to review.")) + return redirect("complaints:complaint_detail", pk=complaint.pk) + + if involved_dept.manager_review_status != "approved": + messages.error(request, _("This response has not been approved by the department manager yet.")) + return redirect("complaints:complaint_detail", pk=complaint.pk) + + acceptance = request.POST.get("acceptance_status") + if acceptance not in ("acceptable", "not_acceptable"): + messages.error(request, _("Invalid acceptance status.")) + return redirect("complaints:complaint_detail", pk=complaint.pk) + + notes = request.POST.get("acceptance_notes", "").strip() + + involved_dept.acceptance_status = acceptance + involved_dept.accepted_by = user + involved_dept.accepted_at = timezone.now() + involved_dept.acceptance_notes = notes + + if acceptance == "not_acceptable": + involved_dept.response_submitted = False + involved_dept.response_submitted_at = None + involved_dept.response_notes = "" + involved_dept.response_notes_en = "" + involved_dept.response_notes_ar = "" + involved_dept.acceptance_status = "not_acceptable" + + involved_dept.save() + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Department response from {involved_dept.department.name} marked as {acceptance} by {user.get_full_name()}. {notes}", + created_by=user, + ) + + AuditService.log_event( + event_type="complaint_dept_response_review", + description=f"Department response for complaint {complaint.reference_number} marked as {acceptance}", + user=user, + content_object=complaint, + ) + + if acceptance == "not_acceptable": + dept = involved_dept.department + recipients = set() + if dept.champion and dept.champion.user and dept.champion.user.email: + recipients.add(dept.champion.user.email) + if dept.manager and dept.manager.email: + recipients.add(dept.manager.email) + for email in recipients: + try: + from apps.notifications.services import NotificationService, get_email_header_html + NotificationService.send_email( + email, + subject=f"Response Rejected - {complaint.reference_number}", + message=( + f"Your department's response for complaint {complaint.reference_number} was not accepted.\n\n" + f"Reason: {notes or 'No reason provided.'}\n\n" + f"Please submit a new response." + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Response Not Accepted

+

Your department's response for complaint {complaint.reference_number} was not accepted.

+

Reason: {notes or 'No reason provided.'}

+

Please review and submit a new response.

+
+
+ """, + related_object=complaint, + ) + except Exception as e: + logger.error(f"Failed to send rejection notification: {e}") + + messages.success(request, _(f"Department response marked as {acceptance}.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -5692,7 +6408,7 @@ def patient_complaint_visit_form(request, token, visit_id): title=title, description=description, encounter_id=visit.admission_id, - complaint_source_type="external", + complaint_source_type="internal", priority="medium", severity="medium", status="open", @@ -5736,7 +6452,7 @@ def government_ticket_list(request): return redirect("dashboard:index") # Base queryset - queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all() + queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all() # Filters source_filter = request.GET.get("source") @@ -5750,7 +6466,7 @@ def government_ticket_list(request): if status_filter: queryset = queryset.filter(status=status_filter) if department_filter: - queryset = queryset.filter(main_section_id=department_filter) + queryset = queryset.filter(department_id=department_filter) if converted_filter: is_converted = converted_filter == "yes" queryset = queryset.filter(converted_to_complaint=is_converted) @@ -5788,7 +6504,7 @@ def government_ticket_list(request): def government_ticket_detail(request, pk): """Detail view for a government ticket""" ticket = get_object_or_404( - GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to", "complaint"), + GovernmentTicket.objects.select_related("source", "department", "assigned_to", "complaint"), pk=pk, ) @@ -5869,12 +6585,11 @@ def convert_to_complaint(request, pk): return redirect("complaints:government_ticket_detail", pk=ticket.pk) # Build redirect URL to complaint create form with pre-filled data - from django.urls import reverse import urllib.parse params = { "source": ticket.source_id, - "complaint_source_type": "external", + "complaint_source_type": "external" if ticket.source.code.upper() in ("MOH", "CCHI") else "internal", "title": f"[{ticket.ticket_number}] {ticket.classification or 'Government Ticket'}", "description": ticket.content, "main_section": ticket.main_section_id or "", @@ -5963,7 +6678,7 @@ def government_ticket_import(request): try: import pandas as pd from apps.px_sources.models import PXSource - from apps.organizations.models import Location, MainSection, SubSection + from apps.organizations.models import LegacyLocation, LegacyMainSection, LegacySubSection from apps.accounts.models import User df = pd.read_json(json_data) @@ -6018,7 +6733,7 @@ def government_ticket_import(request): ticket_location = None if pd.notna(row.get("location")): loc_name = str(row["location"]).strip() - ticket_location = Location.objects.filter( + ticket_location = LegacyLocation.objects.filter( models.Q(name_en__icontains=loc_name) | models.Q(name_ar__icontains=loc_name) ).first() @@ -6026,7 +6741,7 @@ def government_ticket_import(request): ticket_main_section = None if pd.notna(row.get("main_section")): sec_name = str(row["main_section"]).strip() - ticket_main_section = MainSection.objects.filter( + ticket_main_section = LegacyMainSection.objects.filter( models.Q(name_en__icontains=sec_name) | models.Q(name_ar__icontains=sec_name) ).first() @@ -6034,7 +6749,7 @@ def government_ticket_import(request): ticket_subsection = None if pd.notna(row.get("subsection")): sub_name = str(row["subsection"]).strip() - qs = SubSection.objects.filter( + qs = LegacySubSection.objects.filter( models.Q(name_en__icontains=sub_name) | models.Q(name_ar__icontains=sub_name) ) if ticket_location: @@ -6064,9 +6779,9 @@ def government_ticket_import(request): complainant_name=str(row.get("complainant_name", "")).strip() or "Unknown", national_id=str(row.get("national_id", "")).strip() if pd.notna(row.get("national_id")) else "", contact_number=str(row.get("contact_number", "")).strip() if pd.notna(row.get("contact_number")) else "", - location=ticket_location, - main_section=ticket_main_section, - subsection=ticket_subsection, + legacy_location=ticket_location, + legacy_main_section=ticket_main_section, + legacy_subsection=ticket_subsection, received_date=received_date, classification=str(row.get("classification", "")).strip() if pd.notna(row.get("classification")) else "", content=str(row.get("content", "")).strip() if pd.notna(row.get("content")) else "", @@ -6114,7 +6829,7 @@ def government_ticket_export(request): from django.http import HttpResponse # Get filtered queryset (same filters as list view) - queryset = GovernmentTicket.objects.select_related("source", "location", "main_section", "assigned_to").all() + queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all() source_filter = request.GET.get("source") status_filter = request.GET.get("status") @@ -6126,7 +6841,7 @@ def government_ticket_export(request): if status_filter: queryset = queryset.filter(status=status_filter) if department_filter: - queryset = queryset.filter(main_section_id=department_filter) + queryset = queryset.filter(department_id=department_filter) if search_query: queryset = queryset.filter( models.Q(ticket_number__icontains=search_query) @@ -6143,9 +6858,8 @@ def government_ticket_export(request): "اسم المشتكي": ticket.complainant_name, "رقم الهوية": ticket.national_id or "", "رقم التواصل": ticket.contact_number or "", - "الموقع": ticket.location.name_en if ticket.location else "", - "القسم الرئيسي": ticket.main_section.name_en if ticket.main_section else "", - "القسم الفرعي": ticket.subsection.name_en if ticket.subsection else "", + "القسم": ticket.department.name_en if ticket.department else "", + "نوع الموقع": ticket.department.location_type if ticket.department and ticket.department.location_type else "", "تاريخ إنشاء التذكرة": ticket.received_date.strftime("%Y-%m-%d") if ticket.received_date else "", "وقت إنشاء التذكرة": ticket.received_date.strftime("%H:%M:%S") if ticket.received_date else "", "تصنيف الشكوى": ticket.classification or "", diff --git a/apps/complaints/ui_views_explanation.py b/apps/complaints/ui_views_explanation.py index b40acb5..0ef50a9 100644 --- a/apps/complaints/ui_views_explanation.py +++ b/apps/complaints/ui_views_explanation.py @@ -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( diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index c67ec4a..53118d0 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -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("/assign/", ui_views.complaint_assign, name="complaint_assign"), path("/change-status/", ui_views.complaint_change_status, name="complaint_change_status"), path("/update-satisfaction/", ui_views.update_satisfaction, name="update_satisfaction"), + path("/update-patient-contact-status/", ui_views.update_patient_contact_status, name="update_patient_contact_status"), path("/toggle-escalated-ovr/", ui_views.toggle_escalated_ovr, name="toggle_escalated_ovr"), path("/approve-ovr/", ui_views.approve_ovr_escalation, name="approve_ovr_escalation"), path("/reject-ovr/", ui_views.reject_ovr_escalation, name="reject_ovr_escalation"), @@ -131,6 +135,9 @@ urlpatterns = [ path("public/api/hospitals//departments/", api_departments, name="api_departments"), # Public Explanation Form (No Authentication Required) path("/explain//", complaint_explanation_form, name="complaint_explanation_form"), + path("/investigate//", champion_start_investigation, name="champion_start_investigation"), + path("/investigate/respond//", staff_investigation_form, name="staff_investigation_form"), + path("/investigate/review//", champion_review_answers, name="champion_review_answers"), # Patient Complaint Portal (No Authentication Required) path("patient//", ui_views.patient_complaint_portal, name="patient_complaint_portal"), path( @@ -161,6 +168,7 @@ urlpatterns = [ path("departments//edit/", ui_views.involved_department_edit, name="involved_department_edit"), path("departments//remove/", ui_views.involved_department_remove, name="involved_department_remove"), path("departments//response/", ui_views.involved_department_response, name="involved_department_response"), + path("departments//review-response/", ui_views.involved_department_review_response, name="involved_department_review_response"), # Send to Department Form path( "/send-to-department/", ui_views_explanation.send_to_department_form, name="send_to_department_form" diff --git a/apps/complaints/urls_inquiries.py b/apps/complaints/urls_inquiries.py index a322db4..efc9da4 100644 --- a/apps/complaints/urls_inquiries.py +++ b/apps/complaints/urls_inquiries.py @@ -30,6 +30,9 @@ urlpatterns = [ path("public/track/", ui_views.public_inquiry_track, name="public_inquiry_track"), path("/send-to-staff/", ui_views.inquiry_send_to_staff, name="inquiry_send_to_staff"), path("/send-to/", ui_views.inquiry_send_to, name="inquiry_send_to"), + path("/escalate/", ui_views.inquiry_escalate, name="inquiry_escalate"), # Token-based explanation form (No Authentication Required) path("/explain//", views.inquiry_explanation_form, name="inquiry_explanation_form"), + # Token-based department response (No Authentication Required) + path("/respond//", views.inquiry_respond_with_token, name="inquiry_respond_with_token"), ] diff --git a/apps/complaints/utils.py b/apps/complaints/utils.py index 9951eec..a2ba2f2 100644 --- a/apps/complaints/utils.py +++ b/apps/complaints/utils.py @@ -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 = "" diff --git a/apps/complaints/views.py b/apps/complaints/views.py index a07a08f..853d80e 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -5,7 +5,8 @@ Complaints views and viewsets import logging from django.db.models import Q -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, render, redirect +from django.urls import reverse from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action @@ -22,6 +23,7 @@ from .models import ( ComplaintPRInteraction, ComplaintStatus, ComplaintUpdate, + InvestigationStatus, Inquiry, ) from .serializers import ( @@ -819,13 +821,32 @@ This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

{subject}

+

Dear {recipient_display},

+

You have been assigned to review the following complaint:

+ + + + + +
Reference:#{complaint.id}
Title:{complaint.title}
Severity:{complaint.get_severity_display()}
Priority:{complaint.get_priority_display()}
+ +
+
+""", related_object=complaint, metadata={ "notification_type": "complaint_notification", @@ -890,7 +911,7 @@ This is an automated message from PX360 Complaint Management System. ) involved_staff = complaint.involved_staff.select_related( - "staff", "staff__department", "staff__department__respondent" + "staff", "staff__department", "staff__department__champion", "staff__department__manager" ).all() if not involved_staff.exists(): @@ -903,11 +924,11 @@ This is an automated message from PX360 Complaint Management System. for staff_inv in involved_staff: staff = staff_inv.staff dept = staff.department - if dept and dept.respondent: + if dept and dept.champion: 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 hasattr(champion, 'user') and champion.user else None) dept_manager = dept.manager department_groups[dept_key] = { "department_id": dept_key, @@ -1028,7 +1049,7 @@ This is an automated message from PX360 Complaint Management System. # Send email with new link from django.contrib.sites.shortcuts import get_current_site - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{new_token}/" @@ -1083,6 +1104,25 @@ This is an automated message from PX360 Complaint Management System. email=recipient_email, subject=subject, message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Explanation Request (Resent)

+

Dear {recipient_display},

+

We have resent the explanation request for complaint #{complaint.id}:

+ + + + +
Title:{complaint.title}
Severity:{complaint.get_severity_display()}
Priority:{complaint.get_priority_display()}
+

This link can only be used once. After submission, it will expire.

+ +
+
+""", related_object=complaint, metadata={ "notification_type": "explanation_request_resent", @@ -1485,7 +1525,7 @@ This is an automated message from PX360 Complaint Management System. # Send email to manager from django.contrib.sites.shortcuts import get_current_site - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html site = get_current_site(request) explanation_link = f"https://{site.domain}/complaints/{complaint.id}/explain/{manager_token}/" @@ -1536,6 +1576,25 @@ This is an automated message from PX360 Complaint Management System. email=manager_email, subject=subject, message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Escalated Explanation Request

+

The original explanation was not acceptable and this request has been escalated to you for review.

+ + + + + +
Reference:{complaint.reference_number}
Title:{complaint.title}
Severity:{complaint.get_severity_display()}
Priority:{complaint.get_priority_display()}
+

This link can only be used once. After submission, it will expire.

+ +
+
+""", related_object=complaint, metadata={ "notification_type": "escalated_explanation_request", @@ -1904,6 +1963,348 @@ Generate a JSON response with: logger.error(f"AI helper suggestion failed: {e}") return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def _get_pdf_context_data(self, complaint): + """Compute all PDF template context fields from DB data.""" + from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff + + def fmt_date_ar(dt): + if not dt: + return "—" + return dt.strftime("%Y/%m/%d %p %I:%M").replace("AM", "ص").replace("PM", "م") + + hospital_name = ( + complaint.hospital.get_display_name_ar() + if complaint.hospital and complaint.hospital.get_display_name_ar() + else complaint.hospital.get_display_name() if complaint.hospital else "" + ) + dept_name = ( + complaint.department.name_ar + if complaint.department and complaint.department.name_ar + else complaint.department.name_en or complaint.department.name if complaint.department else "" + ) + complainant_name = ( + complaint.contact_name + or complaint.patient_name + or (complaint.patient.get_full_name() if complaint.patient else "") + ) + source_name = complaint.source.name_ar if complaint.source else "" + + accused = ComplaintInvolvedStaff.objects.filter( + complaint=complaint, role="accused" + ).select_related("staff", "staff__department").first() + + accused_staff_name = "" + accused_staff_title = "" + if accused and accused.staff: + s = accused.staff + accused_staff_name = s.name_ar or s.name or f"{s.first_name} {s.last_name}" + accused_staff_title = s.job_title or "" + + sent_to_dept_at = complaint.sent_to_department_at or complaint.forwarded_to_dept_at + + first_response_dept = ComplaintInvolvedDepartment.objects.filter( + complaint=complaint, response_submitted=True + ).order_by("response_submitted_at").first() + response_submitted_at = first_response_dept.response_submitted_at if first_response_dept else None + + return { + "hospital_name": hospital_name, + "department_name": dept_name, + "complainant_name": complainant_name, + "source_name": source_name, + "accused_staff_name": accused_staff_name, + "accused_staff_title": accused_staff_title, + "submission_date": fmt_date_ar(complaint.created_at), + "incident_date": complaint.incident_date.strftime("%Y/%m/%d") if complaint.incident_date else "—", + "sent_to_dept_date": fmt_date_ar(sent_to_dept_at), + "response_date": fmt_date_ar(response_submitted_at), + } + + @action(detail=True, methods=["get", "post"]) + def summary_preview(self, request, pk=None): + """Get existing summary (GET) or generate AI preview (POST).""" + complaint = self.get_object() + + user = request.user + can_generate = ( + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == complaint.hospital) + or (user.is_department_manager() and user.department == complaint.department) + or (user.is_px_employee and complaint.hospital == user.hospital) + ) + if not can_generate: + return Response( + {"error": "You do not have permission to generate a PDF summary"}, + status=status.HTTP_403_FORBIDDEN, + ) + + from .models import ComplaintPdfSummary + + if request.method == "GET": + ctx = self._get_pdf_context_data(complaint) + summary = ComplaintPdfSummary.objects.filter(complaint=complaint).first() + if summary: + data = { + "exists": True, + "content_summary": summary.content_summary, + "dept_response_summary": summary.dept_response_summary, + **ctx, + } + if summary.file: + data["has_file"] = True + data["file_url"] = summary.file.url + else: + data["has_file"] = False + return Response(data) + return Response({"exists": False, **ctx}) + + try: + import json + + from apps.complaints.models import ComplaintInvolvedDepartment + + dept_response_parts = [] + involved_depts = ComplaintInvolvedDepartment.objects.filter( + complaint=complaint + ).select_related("department") + for dept in involved_depts: + resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or "" + resp = resp[:600] + d_name = dept.department.name_ar or dept.department.name_en or dept.department.name + if resp: + dept_response_parts.append(f"- {d_name}: {resp}") + + if not dept_response_parts and complaint.action_taken_by_dept: + dept_response_parts.append(complaint.action_taken_by_dept[:600]) + + dept_response_text = "\n".join(dept_response_parts) or "No department response recorded." + resolution_text = complaint.resolution or complaint.recommendation_action_plan or "" + + prompt = f"""You are a healthcare complaint report writer. Generate your response entirely in Modern Standard Arabic (Fusha). + +COMPLAINT: +- Title: {complaint.title} +- Description: {complaint.description[:2000]} + +DEPARTMENT RESPONSE / ACTIONS TAKEN: +{dept_response_text} + +RESOLUTION: +{resolution_text[:600] or 'No resolution recorded.'} + +Generate JSON with exactly these fields: +- "content_summary": 2-3 paragraph professional summary of the complaint content. +- "department_response_summary": 1-2 paragraph summary of the department response and actions taken.""" + + from apps.core.ai_service import AIService + + result = AIService.chat_completion( + prompt=prompt, + response_format="json_object", + temperature=0.3, + max_tokens=1500, + ) + parsed = json.loads(result) + + content_summary = parsed.get( + "content_summary", complaint.description[:500] if complaint.description else "" + ) + dept_response_summary = parsed.get( + "department_response_summary", + complaint.action_taken_by_dept[:500] if complaint.action_taken_by_dept else "", + ) + + ComplaintPdfSummary.objects.update_or_create( + complaint=complaint, + defaults={ + "lang": "ar", + "content_summary": content_summary, + "dept_response_summary": dept_response_summary, + }, + ) + + ctx = self._get_pdf_context_data(complaint) + + return Response({ + "content_summary": content_summary, + "dept_response_summary": dept_response_summary, + **ctx, + }) + + except Exception as e: + logger.error(f"Error generating summary preview for complaint {pk}: {e}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=["post"]) + def generate_summary_pdf(self, request, pk=None): + """Generate a PDF report for the complaint in the requested language (en/ar).""" + complaint = self.get_object() + + user = request.user + can_generate = ( + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == complaint.hospital) + or (user.is_department_manager() and user.department == complaint.department) + or (user.is_px_employee and complaint.hospital == user.hospital) + ) + if not can_generate: + return Response( + {"error": "You do not have permission to generate a PDF summary"}, + status=status.HTTP_403_FORBIDDEN, + ) + + try: + import base64 + import json + + from django.conf import settings + from django.template.loader import render_to_string + + lang = request.data.get("lang", "ar") + + from apps.complaints.models import ComplaintInvolvedDepartment + + dept_response_parts = [] + involved_depts = ComplaintInvolvedDepartment.objects.filter( + complaint=complaint + ).select_related("department") + for dept in involved_depts: + resp = dept.response_notes_ar or dept.response_notes_en or dept.response_notes or "" + resp = resp[:600] + d_name = dept.department.name_ar or dept.department.name_en or dept.department.name + if resp: + dept_response_parts.append(f"- {d_name}: {resp}") + + if not dept_response_parts and complaint.action_taken_by_dept: + dept_response_parts.append(complaint.action_taken_by_dept[:600]) + + dept_response_text = "\n".join(dept_response_parts) or "No department response recorded." + + resolution_text = complaint.resolution or complaint.recommendation_action_plan or "" + + # Use pre-written text from request, then saved summary, then AI fallback + content_summary = request.data.get("content_summary") + dept_response_summary = request.data.get("dept_response_summary") + + if not content_summary or not dept_response_summary: + from .models import ComplaintPdfSummary + saved = ComplaintPdfSummary.objects.filter(complaint=complaint).first() + if saved and saved.content_summary and saved.dept_response_summary: + content_summary = saved.content_summary + dept_response_summary = saved.dept_response_summary + else: + import json + + prompt = f"""You are a healthcare complaint report writer. Generate your response entirely in Modern Standard Arabic (Fusha). + +COMPLAINT: +- Title: {complaint.title} +- Description: {complaint.description[:2000]} + +DEPARTMENT RESPONSE / ACTIONS TAKEN: +{dept_response_text} + +RESOLUTION: +{resolution_text[:600] or 'No resolution recorded.'} + +Generate JSON with exactly these fields: +- "content_summary": 2-3 paragraph professional summary of the complaint content. +- "department_response_summary": 1-2 paragraph summary of the department response and actions taken.""" + + from apps.core.ai_service import AIService + + result = AIService.chat_completion( + prompt=prompt, + response_format="json_object", + temperature=0.3, + max_tokens=1500, + ) + parsed = json.loads(result) + + content_summary = parsed.get("content_summary", complaint.description[:500] if complaint.description else "") + dept_response_summary = parsed.get( + "department_response_summary", + complaint.action_taken_by_dept[:500] if complaint.action_taken_by_dept else "", + ) + + ctx = self._get_pdf_context_data(complaint) + + for field in ["complainant_name", "source_name", "department_name", + "accused_staff_name", "accused_staff_title", + "submission_date", "incident_date", + "sent_to_dept_date", "response_date"]: + val = request.data.get(field) + if val: + ctx[field] = val + + import io + from PIL import Image as PILImage + _logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_V_Logo(hospital)_.png") + _logo_img.thumbnail((600, 600), PILImage.LANCZOS) + _logo_buf = io.BytesIO() + _logo_img.save(_logo_buf, format="PNG", optimize=True) + logo_path = "data:image/png;base64," + base64.b64encode(_logo_buf.getvalue()).decode() + + html_string = render_to_string( + "complaints/complaint_summary_pdf.html", + { + "complaint": complaint, + "lang": "ar", + "content_summary": content_summary, + "dept_response_summary": dept_response_summary, + "logo_path": logo_path, + "hospital_name_en": complaint.hospital.get_display_name() if complaint.hospital else "", + "hospital_address": complaint.hospital.address if complaint.hospital else "", + "hospital_phone": complaint.hospital.phone if complaint.hospital else "", + "hospital_email": complaint.hospital.email if complaint.hospital else "", + **ctx, + }, + ) + + from weasyprint import HTML + + pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf() + + # Save PDF to persistent storage + from django.core.files.base import ContentFile + from .models import ComplaintPdfSummary + summary, _ = ComplaintPdfSummary.objects.update_or_create( + complaint=complaint, + defaults={ + "lang": "ar", + "content_summary": content_summary, + "dept_response_summary": dept_response_summary, + }, + ) + summary.file.save(f"{complaint.pk}_ar.pdf", ContentFile(pdf_file), save=False) + summary.file_size = len(pdf_file) + summary.save() + + from django.http import HttpResponse + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"complaint_summary_{complaint.reference_number}_ar_{timestamp}.pdf" + + response = HttpResponse(pdf_file, content_type="application/pdf") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + AuditService.log_from_request( + event_type="pdf_summary_generated", + description=f"PDF report ({lang}) generated for complaint: {complaint.reference_number}", + request=request, + content_object=complaint, + metadata={"complaint_id": str(pk), "lang": lang}, + ) + + return response + + except ImportError: + return Response({"error": "WeasyPrint is not installed"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + logger.error(f"Error generating PDF summary for complaint {pk}: {e}") + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + @action(detail=True, methods=["post"]) def save_resolution(self, request, pk=None): """ @@ -2242,13 +2643,33 @@ This is an automated message from PX360 Complaint Management System. """ # Send email using NotificationService - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html try: notification_log = NotificationService.send_email( email=recipient_email, subject=subject, message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Complaint Resolution

+

Dear {recipient_name},

+

We are pleased to inform you that your complaint has been resolved.

+ + + + +
Reference:#{complaint.id}
Title:{complaint.title}
Category:{complaint.get_resolution_category_display()}
+
+

{complaint.resolution}

+
+

If you have any further questions or concerns, please don't hesitate to contact us.

+

Thank you for your patience and for giving us the opportunity to address your concerns.

+
+
+""", related_object=complaint, metadata={ "notification_type": "resolution_notification", @@ -3189,9 +3610,9 @@ def api_locations(request): Returns JSON list of all locations ordered by English name. Public endpoint (no authentication required). """ - from apps.organizations.models import Location + from apps.organizations.models import LegacyLocation - locations = Location.objects.all().order_by("name_en") + locations = LegacyLocation.objects.all().order_by("name_en") locations_list = [ { @@ -3213,14 +3634,14 @@ def api_sections(request, location_id): for given location. Public endpoint (no authentication required). """ - from apps.organizations.models import MainSection, SubSection + from apps.organizations.models import LegacyMainSection, LegacySubSection # Get available sections that have subsections for this location available_section_ids = ( - 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() ) - sections = MainSection.objects.filter(id__in=available_section_ids).order_by("name_en") + sections = LegacyMainSection.objects.filter(id__in=available_section_ids).order_by("name_en") sections_list = [ { @@ -3243,9 +3664,9 @@ def api_subsections(request, location_id, section_id): Returns JSON list of subsections for given location and section. Public endpoint (no authentication required). """ - from apps.organizations.models import SubSection + from apps.organizations.models import LegacySubSection - subsections = SubSection.objects.filter(location_id=location_id, main_section_id=section_id).order_by("name_en") + subsections = LegacySubSection.objects.filter(location_id=location_id, main_section_id=section_id).order_by("name_en") subsections_list = [ { @@ -3299,7 +3720,7 @@ def complaint_explanation_form(request, complaint_id, token): Validates token and checks if it's still valid (not used). """ from .models import ComplaintExplanation, ExplanationAttachment - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html from django.contrib.sites.shortcuts import get_current_site # Get complaint @@ -3365,78 +3786,102 @@ def complaint_explanation_form(request, complaint_id, token): file_size=uploaded_file.size, ) - # Notify complaint assignee - if complaint.assigned_to and complaint.assigned_to.email: - site = get_current_site(request) - complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" - - subject = f"New Explanation Received - Complaint #{complaint.id}" - - email_body = f""" -Dear {complaint.assigned_to.get_full_name()}, - -A new explanation has been submitted for the following complaint: - -COMPLAINT DETAILS: ----------------- -Reference: #{complaint.id} -Title: {complaint.title} -Severity: {complaint.get_severity_display()} - -EXPLANATION SUBMITTED BY: ------------------------- -{explanation.staff} - -EXPLANATION: ------------ -{explanation.explanation} - -""" - if files: - email_body += f""" -ATTACHMENTS: ------------- -{len(files)} file(s) attached -""" - - email_body += f""" - -To view the complaint and explanation, please visit: -{complaint_url} - ---- -This is an automated message from PX360 Complaint Management System. -""" - - try: - NotificationService.send_email( - email=complaint.assigned_to.email, - subject=subject, - message=email_body, - related_object=complaint, - metadata={ - "notification_type": "explanation_submitted", - "explanation_id": str(explanation.id), - "staff_id": str(explanation.staff.id) if explanation.staff else None, - }, - ) - except Exception as e: - # Log error but don't fail the submission - import logging - - logger = logging.getLogger(__name__) - logger.error(f"Failed to send notification email: {e}") - - # Create complaint update - ComplaintUpdate.objects.create( + # Populate the linked ComplaintInvolvedDepartment response for manager review + from apps.complaints.models import ComplaintInvolvedDepartment + involved_dept = ComplaintInvolvedDepartment.objects.filter( complaint=complaint, - update_type="communication", - message=f"Explanation submitted by {explanation.staff}", - metadata={ - "explanation_id": str(explanation.id), - "staff_id": str(explanation.staff.id) if explanation.staff else None, - }, - ) + department=explanation.staff.department, + sent=True, + ).first() + + if involved_dept: + involved_dept.response_notes = explanation_text + involved_dept.response_notes_en = explanation_text + involved_dept.response_submitted = True + involved_dept.response_submitted_at = timezone.now() + involved_dept.manager_review_status = "pending" + involved_dept.manager_reviewed_by = None + involved_dept.manager_reviewed_at = None + involved_dept.acceptance_status = "pending" + involved_dept.accepted_by = None + involved_dept.accepted_at = None + involved_dept.acceptance_notes = "" + involved_dept.save() + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Champion response submitted by {explanation.staff} from {involved_dept.department.name} — awaiting manager review", + metadata={ + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + }, + ) + else: + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="communication", + message=f"Explanation submitted by {explanation.staff}", + metadata={ + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + }, + ) + + # Fallback: notify PX Admin for explanations without linked InvolvedDepartment + if complaint.assigned_to and complaint.assigned_to.email: + site = get_current_site(request) + complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" + try: + NotificationService.send_email( + email=complaint.assigned_to.email, + subject=f"New Explanation Received - Complaint #{complaint.reference_number}", + message=f"An explanation has been submitted for complaint {complaint.reference_number}.\n\nView: {complaint_url}", + related_object=complaint, + metadata={ + "notification_type": "explanation_submitted", + "explanation_id": str(explanation.id), + "staff_id": str(explanation.staff.id) if explanation.staff else None, + }, + ) + except Exception: + pass + + # Notify department manager to review (NOT the PX Admin) + if involved_dept: + dept = involved_dept.department + if dept.manager and dept.manager.email: + try: + review_url = request.build_absolute_uri( + reverse("organizations:department_manager_review", kwargs={"pk": dept.pk, "idept_pk": involved_dept.pk}) + ) + NotificationService.send_email( + dept.manager.email, + subject=f"Champion Response Requires Your Review - {complaint.reference_number}", + message=( + f"A champion response for complaint {complaint.reference_number} " + f"from {dept.name} requires your review and approval.\n\n" + f"Please review at: {review_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Champion Response Requires Your Review

+

A champion from {dept.name} has submitted a response + for complaint {complaint.reference_number}.

+

Your review and approval is required before it is forwarded to the PX team.

+

+ Review Response +

+
+
+ """, + related_object=complaint, + ) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send manager review notification: {e}") # Redirect to success page return render( @@ -3446,13 +3891,485 @@ This is an automated message from PX360 Complaint Management System. ) # GET request - display form + from apps.complaints.models import ComplaintInvolvedStaff + accused_staff = list( + ComplaintInvolvedStaff.objects.filter( + complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED + ).select_related("staff") + ) + + existing_investigation = None + if explanation.is_used: + from apps.complaints.models import ChampionInvestigation + existing_investigation = ChampionInvestigation.objects.filter( + explanation=explanation + ).select_related("champion").first() + return render( request, "complaints/explanation_form.html", - {"complaint": complaint, "explanation": explanation, "original_explanation": original_explanation}, + { + "complaint": complaint, + "explanation": explanation, + "original_explanation": original_explanation, + "accused_staff": accused_staff, + "existing_investigation": existing_investigation, + "investigate_url": request.build_absolute_uri( + reverse("complaints:champion_start_investigation", kwargs={ + "complaint_id": complaint.id, + "token": explanation.token, + }) + ), + }, ) +def champion_start_investigation(request, complaint_id, token): + from .models import ( + ComplaintExplanation, ComplaintInvolvedStaff, + ChampionInvestigation, InvestigationQuestion, InvestigationResponse, + ComplaintUpdate, + ) + from apps.notifications.services import NotificationService + + complaint = get_object_or_404(Complaint, id=complaint_id) + explanation = get_object_or_404( + ComplaintExplanation.objects.select_related("staff", "staff__department"), + complaint=complaint, token=token, + ) + + if explanation.is_used: + return render(request, "complaints/explanation_already_submitted.html", { + "complaint": complaint, "explanation": explanation, + }) + + existing = ChampionInvestigation.objects.filter(explanation=explanation).first() + if existing: + return render(request, "complaints/investigation_already_started.html", { + "complaint": complaint, "investigation": existing, + }) + + accused_staff = list( + ComplaintInvolvedStaff.objects.filter( + complaint=complaint, role=ComplaintInvolvedStaff.RoleChoices.ACCUSED + ).select_related("staff") + ) + + if request.method == "POST": + import secrets + questions = request.POST.getlist("questions[]") + questions = [q.strip() for q in questions if q.strip()] + + selected_staff_ids = request.POST.getlist("accused_staff[]") + selected_staff_ids = [sid for sid in selected_staff_ids if sid] + + if not questions: + return render(request, "complaints/investigation_questions.html", { + "complaint": complaint, + "explanation": explanation, + "accused_staff": accused_staff, + "error": "Please add at least one question.", + }) + + if not selected_staff_ids: + return render(request, "complaints/investigation_questions.html", { + "complaint": complaint, + "explanation": explanation, + "accused_staff": accused_staff, + "error": "Please select at least one accused staff member.", + }) + + involved_dept = explanation.linked_involved_department + + investigation = ChampionInvestigation.objects.create( + complaint=complaint, + champion=explanation.staff, + involved_department=involved_dept, + explanation=explanation, + status="questions_sent", + ) + + for i, q_text in enumerate(questions): + InvestigationQuestion.objects.create( + investigation=investigation, + question_text=q_text, + order=i + 1, + ) + + domain = request.get_host() + staff_count = 0 + for sid in selected_staff_ids: + staff_qs = accused_staff + matched = next((s for s in staff_qs if str(s.staff_id) == sid), None) + if not matched: + try: + from apps.organizations.models import Staff + staff_obj = Staff.objects.get(id=sid) + matched = type('obj', (), {'staff': staff_obj, 'staff_id': staff_obj.id})() + except Exception: + continue + + staff_member = matched.staff if hasattr(matched, 'staff') else matched + resp_token = secrets.token_urlsafe(32) + inv_response = InvestigationResponse.objects.create( + investigation=investigation, + staff=staff_member, + token=resp_token, + ) + + for q in investigation.questions.all(): + InvestigationAnswer.objects.get_or_create( + response=inv_response, question=q, defaults={"answer_text": ""} + ) + + respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/" + + staff_email = staff_member.email or (staff_member.user.email if hasattr(staff_member, 'user') and staff_member.user else None) + if staff_email: + try: + NotificationService.send_email( + email=staff_email, + subject=f"Investigation Questions - Complaint #{complaint.reference_number}", + message=( + f"Dear {staff_member.get_full_name()},\n\n" + f"You have been requested to answer investigation questions " + f"for complaint #{complaint.reference_number}.\n\n" + f"Please respond at: {respond_url}\n\n" + f"Note: This link can only be used once." + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Investigation Questions

+

Dear {staff_member.get_full_name()},

+

You have been requested to answer investigation questions for complaint + #{complaint.reference_number}.

+

The department champion would like your response before proceeding.

+

+ Answer Questions +

+

Note: This link can only be used once.

+
+
+ """, + related_object=complaint, + ) + inv_response.email_sent_at = timezone.now() + inv_response.save(update_fields=["email_sent_at"]) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}") + + staff_phone = staff_member.phone_number or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None) + if staff_phone: + try: + NotificationService.send_sms( + to=staff_phone, + message=f"You have investigation questions for complaint #{complaint.reference_number}. Please respond at: {respond_url}", + ) + inv_response.sms_sent_at = timezone.now() + inv_response.save(update_fields=["sms_sent_at"]) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send investigation SMS to {staff_phone}: {e}") + + staff_count += 1 + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Champion {explanation.staff.get_full_name()} started investigation — questions sent to {staff_count} staff member(s)", + metadata={ + "investigation_id": str(investigation.id), + "staff_count": staff_count, + }, + ) + + return render(request, "complaints/investigation_questions.html", { + "complaint": complaint, + "explanation": explanation, + "accused_staff": accused_staff, + "success": f"Investigation started. Questions sent to {staff_count} staff member(s).", + }) + + return render(request, "complaints/investigation_questions.html", { + "complaint": complaint, + "explanation": explanation, + "accused_staff": accused_staff, + }) + + +def staff_investigation_form(request, complaint_id, token): + from .models import ( + InvestigationResponse, InvestigationAnswer, + ChampionInvestigation, ComplaintUpdate, + ) + from apps.notifications.services import NotificationService, get_email_header_html + + complaint = get_object_or_404(Complaint, id=complaint_id) + inv_response = get_object_or_404( + InvestigationResponse.objects.select_related( + "investigation", "investigation__champion", "staff" + ).prefetch_related("investigation__questions"), + token=token, + ) + + if inv_response.investigation.complaint_id != complaint.id: + return render(request, "complaints/investigation_error.html", { + "error": "Invalid link.", + }) + + if inv_response.is_completed: + return render(request, "complaints/investigation_already_submitted.html", { + "complaint": complaint, "inv_response": inv_response, + }) + + questions = list(inv_response.investigation.questions.all().order_by("order")) + + if request.method == "POST": + for q in questions: + answer_text = request.POST.get(f"question_{q.id}", "").strip() + InvestigationAnswer.objects.update_or_create( + response=inv_response, + question=q, + defaults={"answer_text": answer_text}, + ) + + inv_response.is_completed = True + inv_response.completed_at = timezone.now() + inv_response.save(update_fields=["is_completed", "completed_at"]) + + investigation = inv_response.investigation + if investigation.all_responses_received: + investigation.status = "answers_received" + investigation.save(update_fields=["status"]) + + champion = investigation.champion + if champion and champion.email: + domain = request.get_host() + review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/" + try: + NotificationService.send_email( + email=champion.email, + subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", + message=( + f"Dear {champion.get_full_name()},\n\n" + f"All accused staff have responded to your investigation questions " + f"for complaint #{complaint.reference_number}.\n\n" + f"Please review and submit your final reply: {review_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

All Investigation Responses Received

+

Dear {champion.get_full_name()},

+

All accused staff have responded to your investigation questions for complaint #{complaint.reference_number}.

+

Please review and submit your final reply.

+ +
+
+""", + related_object=complaint, + ) + except Exception: + pass + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Investigation response submitted by {inv_response.staff.get_full_name()}", + ) + + return render(request, "complaints/investigation_success.html", { + "complaint": complaint, + "inv_response": inv_response, + }) + + answers_map = {} + for ans in InvestigationAnswer.objects.filter(response=inv_response).select_related("question"): + answers_map[str(ans.question_id)] = ans.answer_text + + question_answer_pairs = [] + for q in questions: + question_answer_pairs.append({ + "question": q, + "existing_answer": answers_map.get(str(q.id), ""), + }) + + return render(request, "complaints/investigation_respond.html", { + "complaint": complaint, + "inv_response": inv_response, + "question_answer_pairs": question_answer_pairs, + }) + + +def champion_review_answers(request, complaint_id, token): + from .models import ( + ComplaintExplanation, ComplaintInvolvedDepartment, + ChampionInvestigation, ComplaintUpdate, + ) + from apps.notifications.services import NotificationService, get_email_header_html + + complaint = get_object_or_404(Complaint, id=complaint_id) + explanation = get_object_or_404( + ComplaintExplanation.objects.select_related("staff", "staff__department"), + complaint=complaint, token=token, + ) + + investigation = ChampionInvestigation.objects.filter( + explanation=explanation + ).prefetch_related( + "questions", "responses__staff", "responses__answers__question" + ).first() + + if not investigation: + return render(request, "complaints/investigation_error.html", { + "error": "No investigation found for this complaint.", + }) + + if investigation.status == InvestigationStatus.REPLY_SUBMITTED: + return render(request, "complaints/investigation_already_submitted.html", { + "complaint": complaint, "investigation": investigation, + }) + + responses = list(investigation.responses.all().select_related("staff")) + questions = list(investigation.questions.all().order_by("order")) + + answer_lookup = {} + from .models import InvestigationAnswer + for ans in InvestigationAnswer.objects.filter( + response__investigation=investigation + ).select_related("question"): + key = (str(ans.response_id), str(ans.question_id)) + answer_lookup[key] = ans.answer_text + + staff_data = [] + for resp in responses: + qa_pairs = [] + for q in questions: + key = (str(resp.id), str(q.id)) + qa_pairs.append({ + "question": q, + "answer": answer_lookup.get(key, ""), + }) + staff_data.append({ + "response": resp, + "staff": resp.staff, + "is_completed": resp.is_completed, + "qa_pairs": qa_pairs, + }) + + if request.method == "POST": + final_reply = request.POST.get("final_reply", "").strip() + if not final_reply: + return render(request, "complaints/investigation_review.html", { + "complaint": complaint, + "explanation": explanation, + "investigation": investigation, + "questions": questions, + "staff_data": staff_data, + "error": "Please provide your final reply.", + }) + + investigation.final_reply = final_reply + investigation.status = InvestigationStatus.REPLY_SUBMITTED + investigation.save(update_fields=["final_reply", "status"]) + + explanation.explanation = final_reply + explanation.is_used = True + explanation.responded_at = timezone.now() + explanation.save(update_fields=["explanation", "is_used", "responded_at"]) + + involved_dept = investigation.involved_department or explanation.linked_involved_department + if involved_dept: + involved_dept.response_notes = final_reply + involved_dept.response_notes_en = final_reply + involved_dept.response_submitted = True + involved_dept.response_submitted_at = timezone.now() + involved_dept.manager_review_status = "pending" + involved_dept.manager_reviewed_by = None + involved_dept.manager_reviewed_at = None + involved_dept.acceptance_status = "pending" + involved_dept.accepted_by = None + involved_dept.accepted_at = None + involved_dept.acceptance_notes = "" + involved_dept.save() + + dept = involved_dept.department + if dept and dept.manager and dept.manager.email: + try: + review_url = request.build_absolute_uri( + reverse("organizations:department_manager_review", kwargs={ + "pk": dept.pk, + "idept_pk": involved_dept.pk, + }) + ) + NotificationService.send_email( + dept.manager.email, + subject=f"Champion Response Requires Your Review - {complaint.reference_number}", + message=( + f"A champion response for complaint {complaint.reference_number} " + f"from {dept.name} requires your review and approval.\n\n" + f"Please review at: {review_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Champion Response Requires Review

+

A champion response for complaint {complaint.reference_number} from {dept.name} requires your review and approval.

+ +
+
+""", + related_object=complaint, + ) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send manager review notification: {e}") + + files = request.FILES.getlist("attachments") + from apps.complaints.models import ExplanationAttachment + for f in files: + ExplanationAttachment.objects.create( + explanation=explanation, + file=f, + filename=f.name, + file_type=f.content_type, + file_size=f.size, + ) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Champion {explanation.staff.get_full_name()} submitted final reply after investigation — awaiting manager review", + metadata={ + "investigation_id": str(investigation.id), + "response_count": len(responses), + }, + ) + + return render(request, "complaints/explanation_success.html", { + "complaint": complaint, + "explanation": explanation, + "attachment_count": len(files), + }) + + return render(request, "complaints/investigation_review.html", { + "complaint": complaint, + "explanation": explanation, + "investigation": investigation, + "questions": questions, + "staff_data": staff_data, + }) + + from django.http import HttpResponse from django.utils.translation import gettext as _ @@ -3624,3 +4541,84 @@ def inquiry_explanation_form(request, inquiry_id, token): "complaints/inquiry_explanation_form.html", {"inquiry": inquiry, "explanation": explanation}, ) + + +def inquiry_respond_with_token(request, pk, token): + """ + Public-facing form for department staff to submit response to an inquiry. + Does NOT require authentication. Validates token and checks validity. + """ + from apps.core.ai_service import AIService + from apps.notifications.services import NotificationService + from apps.core.utils import build_public_track_url + + inquiry = get_object_or_404(Inquiry, pk=pk) + + if inquiry.response_token != token or not inquiry.response_token: + return render(request, "complaints/inquiry_response_token_invalid.html", {"inquiry": inquiry}) + + if inquiry.response_token_used: + return render(request, "complaints/inquiry_response_already_submitted.html", {"inquiry": inquiry}) + + if request.method == "POST": + response_en = request.POST.get("response_en", "").strip() + response_ar = request.POST.get("response_ar", "").strip() + response = response_en or response_ar + + if not response: + return render(request, "complaints/inquiry_response_form_token.html", { + "inquiry": inquiry, + "error": "Please enter a response in at least one language.", + }) + + inquiry.department_response_en = response_en + inquiry.department_response_ar = response_ar + inquiry.department_responded_at = timezone.now() + inquiry.dept_response_is_overdue = False + inquiry.dept_response_acceptance_status = "pending" + inquiry.response_token_used = True + inquiry.save() + + # AI summary + try: + import json + prompt = f"""Summarize the following department response to a patient inquiry in 2-3 concise sentences. + +Inquiry subject: {inquiry.subject} +Inquiry message: {(inquiry.message or '')[:500]} +Department response: {response[:500]} + +Generate JSON with "summary_en" and "summary_ar".""" + result = AIService.chat_completion(prompt=prompt, response_format="json_object") + parsed = json.loads(result) + inquiry.department_response_summary_en = parsed.get("summary_en", "") + inquiry.department_response_summary_ar = parsed.get("summary_ar", "") + inquiry.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) + except Exception: + pass + + # Notify inquirer + track_url = build_public_track_url("inquiry", inquiry.reference_number) + if inquiry.contact_phone: + try: + NotificationService.send_sms( + phone=inquiry.contact_phone, + message=f"PX360: Your inquiry {inquiry.reference_number} has been responded to. View: {track_url}", + related_object=inquiry, + ) + except Exception: + pass + if inquiry.contact_email: + try: + NotificationService.send_email( + email=inquiry.contact_email, + subject=f"PX360: Response to Your Inquiry {inquiry.reference_number}", + message=f"Your inquiry has been responded to.\n\nView: {track_url}", + related_object=inquiry, + ) + except Exception: + pass + + return render(request, "complaints/inquiry_response_success_token.html", {"inquiry": inquiry}) + + return render(request, "complaints/inquiry_response_form_token.html", {"inquiry": inquiry}) diff --git a/apps/core/ai_service.py b/apps/core/ai_service.py index 5f2fab0..c4d2dca 100644 --- a/apps/core/ai_service.py +++ b/apps/core/ai_service.py @@ -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 "")) diff --git a/apps/core/config_views.py b/apps/core/config_views.py index 10ae4ea..c391618 100644 --- a/apps/core/config_views.py +++ b/apps/core/config_views.py @@ -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, } diff --git a/apps/core/management/commands/create_e2e_isolated_env.py b/apps/core/management/commands/create_e2e_isolated_env.py new file mode 100644 index 0000000..5171655 --- /dev/null +++ b/apps/core/management/commands/create_e2e_isolated_env.py @@ -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()}") diff --git a/apps/core/management/commands/create_e2e_test_users.py b/apps/core/management/commands/create_e2e_test_users.py index be73dc1..7c7dbdb 100644 --- a/apps/core/management/commands/create_e2e_test_users.py +++ b/apps/core/management/commands/create_e2e_test_users.py @@ -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, diff --git a/apps/core/management/commands/send_example_emails.py b/apps/core/management/commands/send_example_emails.py index 85030c5..ea0c2d8 100644 --- a/apps/core/management/commands/send_example_emails.py +++ b/apps/core/management/commands/send_example_emails.py @@ -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 diff --git a/apps/core/management/commands/setup_dev_environment.py b/apps/core/management/commands/setup_dev_environment.py index 66be3c4..07a8bd0 100644 --- a/apps/core/management/commands/setup_dev_environment.py +++ b/apps/core/management/commands/setup_dev_environment.py @@ -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: diff --git a/apps/core/management/commands/test_email.py b/apps/core/management/commands/test_email.py new file mode 100644 index 0000000..1e26387 --- /dev/null +++ b/apps/core/management/commands/test_email.py @@ -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}")) diff --git a/apps/core/middleware.py b/apps/core/middleware.py index 223d2bb..de8714e 100644 --- a/apps/core/middleware.py +++ b/apps/core/middleware.py @@ -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 diff --git a/apps/core/migrations/0002_add_note_model.py b/apps/core/migrations/0002_add_note_model.py new file mode 100644 index 0000000..4c31715 --- /dev/null +++ b/apps/core/migrations/0002_add_note_model.py @@ -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')], + }, + ), + ] diff --git a/apps/core/migrations/0003_referencesequence.py b/apps/core/migrations/0003_referencesequence.py new file mode 100644 index 0000000..5cc4fd3 --- /dev/null +++ b/apps/core/migrations/0003_referencesequence.py @@ -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')}, + }, + ), + ] diff --git a/apps/core/models.py b/apps/core/models.py index fff46be..1ee97a0 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -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 diff --git a/apps/core/reference.py b/apps/core/reference.py new file mode 100644 index 0000000..7bd95c6 --- /dev/null +++ b/apps/core/reference.py @@ -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}" diff --git a/apps/core/templatetags/email_tags.py b/apps/core/templatetags/email_tags.py index 99881d4..640c857 100644 --- a/apps/core/templatetags/email_tags.py +++ b/apps/core/templatetags/email_tags.py @@ -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") diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..5b10caf --- /dev/null +++ b/apps/core/tests.py @@ -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) diff --git a/apps/core/urls.py b/apps/core/urls.py index 3aa9a76..3d7ec01 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -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) diff --git a/apps/core/utils.py b/apps/core/utils.py index 5e113d0..5655e9e 100644 --- a/apps/core/utils.py +++ b/apps/core/utils.py @@ -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}" diff --git a/apps/core/views.py b/apps/core/views.py index 8181b92..976886a 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -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}) diff --git a/apps/dashboard/services/complaint_monthly_export.py b/apps/dashboard/services/complaint_monthly_export.py index 1abdde0..e6ccc1b 100644 --- a/apps/dashboard/services/complaint_monthly_export.py +++ b/apps/dashboard/services/complaint_monthly_export.py @@ -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, diff --git a/apps/dashboard/services/complaint_monthly_service.py b/apps/dashboard/services/complaint_monthly_service.py index c828878..b5d9478 100644 --- a/apps/dashboard/services/complaint_monthly_service.py +++ b/apps/dashboard/services/complaint_monthly_service.py @@ -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): diff --git a/apps/dashboard/services/complaint_quarterly_export.py b/apps/dashboard/services/complaint_quarterly_export.py index d3ccda1..21eea96 100644 --- a/apps/dashboard/services/complaint_quarterly_export.py +++ b/apps/dashboard/services/complaint_quarterly_export.py @@ -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: diff --git a/apps/dashboard/services/complaint_quarterly_service.py b/apps/dashboard/services/complaint_quarterly_service.py index 6d87046..f830cbb 100644 --- a/apps/dashboard/services/complaint_quarterly_service.py +++ b/apps/dashboard/services/complaint_quarterly_service.py @@ -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, diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index e582ec3..0ea3c8f 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -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}, ] diff --git a/apps/feedback/admin.py b/apps/feedback/admin.py index 93546ea..6ec42c8 100644 --- a/apps/feedback/admin.py +++ b/apps/feedback/admin.py @@ -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", { diff --git a/apps/feedback/forms.py b/apps/feedback/forms.py index cdfe954..adb24a5 100644 --- a/apps/feedback/forms.py +++ b/apps/feedback/forms.py @@ -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") diff --git a/apps/feedback/migrations/0001_initial.py b/apps/feedback/migrations/0001_initial.py index 0324a65..f1dd9dc 100644 --- a/apps/feedback/migrations/0001_initial.py +++ b/apps/feedback/migrations/0001_initial.py @@ -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={ diff --git a/apps/feedback/migrations/0003_initial.py b/apps/feedback/migrations/0003_initial.py index 4b1a00d..7bb4774 100644 --- a/apps/feedback/migrations/0003_initial.py +++ b/apps/feedback/migrations/0003_initial.py @@ -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', diff --git a/apps/feedback/migrations/0004_remove_feedback_location_and_more.py b/apps/feedback/migrations/0004_remove_feedback_location_and_more.py new file mode 100644 index 0000000..7611342 --- /dev/null +++ b/apps/feedback/migrations/0004_remove_feedback_location_and_more.py @@ -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', + ), + ] diff --git a/apps/feedback/migrations/0005_feedback_legacy_location_and_more.py b/apps/feedback/migrations/0005_feedback_legacy_location_and_more.py new file mode 100644 index 0000000..ede5ca0 --- /dev/null +++ b/apps/feedback/migrations/0005_feedback_legacy_location_and_more.py @@ -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), + ), + ] diff --git a/apps/feedback/migrations/0006_alter_feedback_legacy_location_and_more.py b/apps/feedback/migrations/0006_alter_feedback_legacy_location_and_more.py new file mode 100644 index 0000000..eb7af47 --- /dev/null +++ b/apps/feedback/migrations/0006_alter_feedback_legacy_location_and_more.py @@ -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'), + ), + ] diff --git a/apps/feedback/migrations/0007_remove_sub_subsection.py b/apps/feedback/migrations/0007_remove_sub_subsection.py new file mode 100644 index 0000000..a1399db --- /dev/null +++ b/apps/feedback/migrations/0007_remove_sub_subsection.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('feedback', '0006_alter_feedback_legacy_location_and_more'), + ] + + operations = [] diff --git a/apps/feedback/migrations/0008_feedback_reference_number_and_more.py b/apps/feedback/migrations/0008_feedback_reference_number_and_more.py new file mode 100644 index 0000000..651e6eb --- /dev/null +++ b/apps/feedback/migrations/0008_feedback_reference_number_and_more.py @@ -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'), + ), + ] diff --git a/apps/feedback/models.py b/apps/feedback/models.py index 2f4c97c..31f883e 100644 --- a/apps/feedback/models.py +++ b/apps/feedback/models.py @@ -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 diff --git a/apps/feedback/tasks.py b/apps/feedback/tasks.py index 7aa52d0..b073d30 100644 --- a/apps/feedback/tasks.py +++ b/apps/feedback/tasks.py @@ -1,6 +1,7 @@ import logging from celery import shared_task +from django.utils import timezone logger = logging.getLogger(__name__) diff --git a/apps/feedback/views.py b/apps/feedback/views.py index 39e56fe..c5f25d8 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -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") diff --git a/apps/integrations/admin.py b/apps/integrations/admin.py index 36f0809..b89c2e5 100644 --- a/apps/integrations/admin.py +++ b/apps/integrations/admin.py @@ -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('All Entities') + 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 diff --git a/apps/integrations/api_serializers.py b/apps/integrations/api_serializers.py new file mode 100644 index 0000000..b581603 --- /dev/null +++ b/apps/integrations/api_serializers.py @@ -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 diff --git a/apps/integrations/api_views.py b/apps/integrations/api_views.py new file mode 100644 index 0000000..b0fbc08 --- /dev/null +++ b/apps/integrations/api_views.py @@ -0,0 +1,1107 @@ +""" +External API views for complaints, inquiries, observations, appreciations, and suggestions. + +All endpoints require X-API-Key header authentication. +""" + +import logging + +from django.utils import timezone +from rest_framework import generics, status +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.complaints.models import Complaint, ComplaintSourceType, ComplaintUpdate, Inquiry +from apps.core.services import AuditService +from apps.observations.models import Observation +from apps.appreciation.models import Appreciation, AppreciationStatus, AppreciationVisibility +from apps.feedback.models import Feedback, FeedbackType, FeedbackStatus, FeedbackCategory + +from .api_serializers import ( + ExternalAppreciationCreateSerializer, + ExternalAppreciationRetrieveSerializer, + ExternalComplaintCreateSerializer, + ExternalComplaintRetrieveSerializer, + ExternalComplaintSatisfactionSerializer, + ExternalDoctorRatingCreateSerializer, + ExternalInquiryCreateSerializer, + ExternalInquiryRetrieveSerializer, + ExternalObservationCreateSerializer, + ExternalObservationRetrieveSerializer, + ExternalSuggestionCreateSerializer, + ExternalSuggestionRetrieveSerializer, +) +from .authentication import ExternalAPIKeyAuthentication + + +logger = logging.getLogger("apps.integrations") + + +# --------------------------------------------------------------------------- +# Base mixin +# --------------------------------------------------------------------------- + +class ExternalAPIBase: + """Shared behaviour for all external API views.""" + + authentication_classes = [ExternalAPIKeyAuthentication] + permission_classes = [] + entity_name = None # Override in subclass + + def check_entity_access(self): + """Ensure the API key is allowed to access this entity.""" + api_key = getattr(self.request, "api_key", None) + if api_key and not api_key.can_access(self.entity_name): + return Response( + {"detail": f"This API key does not have access to {self.entity_name}."}, + status=status.HTTP_403_FORBIDDEN, + ) + return None + + def filter_queryset_by_hospital(self, qs): + """Scope query to the API key's hospital (if set).""" + api_key = getattr(self.request, "api_key", None) + if api_key and api_key.hospital: + hospital_field = self._get_hospital_field() + if hospital_field: + qs = qs.filter(**{hospital_field: api_key.hospital}) + return qs + + def _get_hospital_field(self): + """Return the ORM field name for hospital on the model.""" + return "hospital" + + def apply_list_filters(self, qs, request): + """Apply date range and status filters from query params.""" + + created_from = request.query_params.get("created_from", "").strip() + created_to = request.query_params.get("created_to", "").strip() + updated_from = request.query_params.get("updated_from", "").strip() + updated_to = request.query_params.get("updated_to", "").strip() + status_val = request.query_params.get("status", "").strip() + + if created_from: + try: + qs = qs.filter(created_at__gte=self._parse_date(created_from, start=True)) + except (ValueError, TypeError): + pass + + if created_to: + try: + qs = qs.filter(created_at__lte=self._parse_date(created_to, start=False)) + except (ValueError, TypeError): + pass + + if updated_from: + try: + qs = qs.filter(updated_at__gte=self._parse_date(updated_from, start=True)) + except (ValueError, TypeError): + pass + + if updated_to: + try: + qs = qs.filter(updated_at__lte=self._parse_date(updated_to, start=False)) + except (ValueError, TypeError): + pass + + if status_val: + qs = qs.filter(status=status_val) + + return qs + + def _parse_date(self, value, start=True): + """Parse a date string. If date-only, start=True → 00:00, start=False → 23:59:59.""" + from datetime import datetime + from django.utils import timezone as tz + + for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"): + try: + dt = datetime.strptime(value, fmt) + if fmt == "%Y-%m-%d" and not start: + dt = dt.replace(hour=23, minute=59, second=59) + return tz.make_aware(dt) if tz.is_naive(dt) else dt + except ValueError: + continue + raise ValueError(f"Invalid date format: {value}") + + +# --------------------------------------------------------------------------- +# Complaints +# --------------------------------------------------------------------------- + +class ExternalComplaintCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/complaints/ + + Create a complaint via external API. + """ + + entity_name = "complaints" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalComplaintCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + # Enforce hospital scoping + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and data["hospital"] != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) + + complaint = Complaint.objects.create( + patient=None, + hospital=data["hospital"], + department=data.get("department"), + section=data.get("section"), + area=data.get("area"), + location_type=data.get("location_type", ""), + title=data["title"], + description=data["description"], + severity="medium", + priority="medium", + status="open", + complaint_source_type=ComplaintSourceType.EXTERNAL, + contact_name=data["contact_name"], + contact_phone=data["contact_phone"], + contact_email=data.get("contact_email", ""), + relation_to_patient=data.get("relation_to_patient", ""), + patient_name=data.get("patient_name", ""), + national_id=data.get("national_id", ""), + incident_date=data.get("incident_date"), + expected_result=data.get("expected_result", ""), + metadata={"source": "external_api"}, + ) + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message="Complaint submitted via external API.", + ) + + # Trigger background tasks + try: + from apps.complaints.tasks import analyze_complaint_with_ai, notify_staff_new_item + + analyze_complaint_with_ai.delay(str(complaint.id)) + notify_staff_new_item.delay("complaint", str(complaint.id)) + except Exception: + pass + + AuditService.log_event( + event_type="external_complaint_created", + description=f"Complaint created via external API: {reference_number}", + content_object=complaint, + metadata={"reference": reference_number, "source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": reference_number, + "status": complaint.status, + }, + status=status.HTTP_201_CREATED, + ) + + +class ExternalComplaintRetrieveView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/complaints/ + GET /api/v1/external/complaints// + + List complaints (scoped to hospital) or retrieve a single complaint by reference number. + """ + + entity_name = "complaints" + + def get(self, request, reference_number=None): + access_err = self.check_entity_access() + if access_err: + return access_err + + if reference_number: + return self._retrieve(request, reference_number) + return self._list(request) + + def _retrieve(self, request, reference_number): + qs = Complaint.all_objects.filter(reference_number=reference_number) + qs = self.filter_queryset_by_hospital(qs) + complaint = qs.first() + if not complaint: + return Response({"detail": "Complaint not found."}, status=status.HTTP_404_NOT_FOUND) + + ser = ExternalComplaintRetrieveSerializer(complaint) + return Response(ser.data) + + def _list(self, request): + qs = Complaint.all_objects.all() + qs = self.filter_queryset_by_hospital(qs) + qs = self.apply_list_filters(qs, request) + + page_size = min(int(request.query_params.get("page_size", 20)), 100) + page = int(request.query_params.get("page", 1)) + offset = (page - 1) * page_size + total = qs.count() + + items = qs[offset : offset + page_size] + ser = ExternalComplaintRetrieveSerializer(items, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": ser.data, + } + ) + + +class ExternalComplaintSatisfactionView(ExternalAPIBase, APIView): + """ + PATCH /api/v1/external/complaints//satisfaction/ + + Set patient satisfaction for a complaint. + """ + + entity_name = "complaints" + + def patch(self, request, reference_number): + access_err = self.check_entity_access() + if access_err: + return access_err + + qs = Complaint.all_objects.filter(reference_number=reference_number) + qs = self.filter_queryset_by_hospital(qs) + complaint = qs.first() + if not complaint: + return Response({"detail": "Complaint not found."}, status=status.HTTP_404_NOT_FOUND) + + if not complaint.resolution: + return Response( + {"detail": "Cannot set satisfaction before a resolution is recorded."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ser = ExternalComplaintSatisfactionSerializer(data=request.data) + ser.is_valid(raise_exception=True) + + complaint.satisfaction = ser.validated_data["satisfaction"] + complaint.satisfaction_set_at = timezone.now() + complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"]) + + AuditService.log_event( + event_type="external_complaint_satisfaction_set", + description=f"Satisfaction set to '{complaint.satisfaction}' via external API for {reference_number}", + content_object=complaint, + metadata={"reference": reference_number, "satisfaction": complaint.satisfaction, "source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": complaint.reference_number, + "satisfaction": complaint.satisfaction, + } + ) + + +# --------------------------------------------------------------------------- +# Inquiries +# --------------------------------------------------------------------------- + +class ExternalInquiryCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/inquiries/ + + Create an inquiry via external API. + """ + + entity_name = "inquiries" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalInquiryCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and data["hospital"] != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + import uuid + from datetime import datetime + + today = datetime.now().strftime("%Y%m%d") + random_suffix = str(uuid.uuid4().int)[:6] + reference_number = f"INQ-{today}-{random_suffix}" + + inquiry = Inquiry.objects.create( + patient=None, + hospital=data["hospital"], + department=data.get("department"), + section=data.get("section"), + area=data.get("area"), + location_type=data.get("location_type", ""), + subject=data["subject"], + message=data["message"], + category=data.get("category", "general"), + contact_name=data["contact_name"], + contact_phone=data["contact_phone"], + contact_email=data.get("contact_email", ""), + status="open", + reference_number=reference_number, + is_outgoing=False, + metadata={"source": "external_api"}, + ) + + try: + 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 + + AuditService.log_event( + event_type="external_inquiry_created", + description=f"Inquiry created via external API: {reference_number}", + content_object=inquiry, + metadata={"reference": reference_number, "source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": reference_number, + "status": inquiry.status, + }, + status=status.HTTP_201_CREATED, + ) + + +class ExternalInquiryRetrieveView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/inquiries/ + GET /api/v1/external/inquiries// + """ + + entity_name = "inquiries" + + def get(self, request, reference_number=None): + access_err = self.check_entity_access() + if access_err: + return access_err + + if reference_number: + return self._retrieve(request, reference_number) + return self._list(request) + + def _retrieve(self, request, reference_number): + qs = Inquiry.all_objects.filter(reference_number=reference_number) + qs = self.filter_queryset_by_hospital(qs) + inquiry = qs.first() + if not inquiry: + return Response({"detail": "Inquiry not found."}, status=status.HTTP_404_NOT_FOUND) + + ser = ExternalInquiryRetrieveSerializer(inquiry) + return Response(ser.data) + + def _list(self, request): + qs = Inquiry.all_objects.all() + qs = self.filter_queryset_by_hospital(qs) + qs = self.apply_list_filters(qs, request) + + page_size = min(int(request.query_params.get("page_size", 20)), 100) + page = int(request.query_params.get("page", 1)) + offset = (page - 1) * page_size + total = qs.count() + + items = qs[offset : offset + page_size] + ser = ExternalInquiryRetrieveSerializer(items, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": ser.data, + } + ) + + +# --------------------------------------------------------------------------- +# Observations +# --------------------------------------------------------------------------- + +class ExternalObservationCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/observations/ + + Create an observation via external API. + """ + + entity_name = "observations" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalObservationCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and data["hospital"] != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + from django.utils import timezone as tz + + observation = Observation.objects.create( + hospital=data["hospital"], + category=data.get("category"), + title=data.get("title", ""), + description=data["description"], + severity=data.get("severity", "medium"), + location_text=data.get("location_text", ""), + incident_datetime=data.get("incident_datetime") or tz.now(), + reporter_staff_id=data.get("reporter_staff_id", ""), + reporter_name=data.get("contact_name", ""), + reporter_phone=data.get("contact_phone", ""), + reporter_email=data.get("contact_email", ""), + patient_file_number=data.get("patient_file_number", ""), + source="other", + status="new", + metadata={"source": "external_api"}, + ) + + try: + from apps.complaints.tasks import notify_staff_new_item + + notify_staff_new_item.delay("observation", str(observation.id)) + except Exception: + pass + + AuditService.log_event( + event_type="external_observation_created", + description=f"Observation created via external API: {observation.tracking_code}", + content_object=observation, + metadata={"reference_number": observation.tracking_code, "source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": observation.tracking_code, + "status": observation.status, + }, + status=status.HTTP_201_CREATED, + ) + + +class ExternalObservationRetrieveView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/observations/ + GET /api/v1/external/observations// + """ + + entity_name = "observations" + + def get(self, request, tracking_code=None): + access_err = self.check_entity_access() + if access_err: + return access_err + + if tracking_code: + return self._retrieve(request, tracking_code) + return self._list(request) + + def _retrieve(self, request, tracking_code): + qs = Observation.all_objects.filter(tracking_code=tracking_code) + qs = self.filter_queryset_by_hospital(qs) + observation = qs.first() + if not observation: + return Response({"detail": "Observation not found."}, status=status.HTTP_404_NOT_FOUND) + + ser = ExternalObservationRetrieveSerializer(observation) + return Response(ser.data) + + def _list(self, request): + qs = Observation.all_objects.all() + qs = self.filter_queryset_by_hospital(qs) + qs = self.apply_list_filters(qs, request) + + page_size = min(int(request.query_params.get("page_size", 20)), 100) + page = int(request.query_params.get("page", 1)) + offset = (page - 1) * page_size + total = qs.count() + + items = qs[offset : offset + page_size] + ser = ExternalObservationRetrieveSerializer(items, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": ser.data, + } + ) + + +# --------------------------------------------------------------------------- +# Appreciations +# --------------------------------------------------------------------------- + +class ExternalAppreciationCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/appreciations/ + + Create an appreciation via external API. + """ + + entity_name = "appreciations" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalAppreciationCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and data["hospital"] != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + import uuid + from datetime import datetime + + today = datetime.now().strftime("%Y%m%d") + random_suffix = str(uuid.uuid4().int)[:6] + reference_number = f"APR-{today}-{random_suffix}" + + appreciation = Appreciation.objects.create( + hospital=data["hospital"], + message_en=data["message"], + message_ar="", + category=None, + is_anonymous=False, + status=AppreciationStatus.DRAFT, + visibility=AppreciationVisibility.PUBLIC, + metadata={ + "source": "external_api", + "reference_number": reference_number, + "submitted_by_name": data["contact_name"], + "submitted_by_phone": data["contact_phone"], + }, + ) + + try: + from apps.complaints.tasks import notify_staff_new_item + + notify_staff_new_item.delay("appreciation", str(appreciation.id)) + except Exception: + pass + + AuditService.log_event( + event_type="external_appreciation_created", + description=f"Appreciation created via external API", + content_object=appreciation, + metadata={"source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": reference_number, + "status": appreciation.status, + }, + status=status.HTTP_201_CREATED, + ) + + +class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/appreciations/ + GET /api/v1/external/appreciations// + """ + + entity_name = "appreciations" + + def get(self, request, pk=None): + access_err = self.check_entity_access() + if access_err: + return access_err + + if pk: + return self._retrieve(request, pk) + return self._list(request) + + def _retrieve(self, request, pk): + qs = Appreciation.all_objects.filter(pk=pk) + qs = self.filter_queryset_by_hospital(qs) + appreciation = qs.first() + if not appreciation: + return Response({"detail": "Appreciation not found."}, status=status.HTTP_404_NOT_FOUND) + + ser = ExternalAppreciationRetrieveSerializer(appreciation) + return Response(ser.data) + + def _list(self, request): + qs = Appreciation.all_objects.all() + qs = self.filter_queryset_by_hospital(qs) + qs = self.apply_list_filters(qs, request) + + page_size = min(int(request.query_params.get("page_size", 20)), 100) + page = int(request.query_params.get("page", 1)) + offset = (page - 1) * page_size + total = qs.count() + + items = qs[offset : offset + page_size] + ser = ExternalAppreciationRetrieveSerializer(items, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": ser.data, + } + ) + + +# --------------------------------------------------------------------------- +# Suggestions (Feedback) +# --------------------------------------------------------------------------- + +class ExternalSuggestionCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/suggestions/ + + Create a suggestion via external API. + """ + + entity_name = "suggestions" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalSuggestionCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and data["hospital"] != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + category_map = { + "general": FeedbackCategory.OTHER, + "clinical_care": FeedbackCategory.CLINICAL_CARE, + "facility": FeedbackCategory.FACILITY, + "staff_service": FeedbackCategory.STAFF_SERVICE, + "communication": FeedbackCategory.COMMUNICATION, + "technology": FeedbackCategory.TECHNOLOGY, + "food_service": FeedbackCategory.FOOD_SERVICE, + "appointment": FeedbackCategory.APPOINTMENT, + "other": FeedbackCategory.OTHER, + } + + import uuid as _uuid + from datetime import datetime + + today = datetime.now().strftime("%Y%m%d") + random_suffix = str(_uuid.uuid4().int)[:6] + reference_number = f"SG-{today}-{random_suffix}" + + title = data.get("title") or data["message"][:100] + + feedback = Feedback.objects.create( + hospital=data["hospital"], + feedback_type=FeedbackType.SUGGESTION, + title=title, + message=data["message"], + category=category_map.get(data.get("category", "general"), FeedbackCategory.OTHER), + contact_name=data["contact_name"], + contact_phone=data["contact_phone"], + rating=data.get("rating"), + is_anonymous=False, + status=FeedbackStatus.SUBMITTED, + metadata={ + "source": "external_api", + "reference_number": reference_number, + "suggestion_area": data.get("category", "general"), + }, + ) + + try: + from apps.feedback.tasks import analyze_suggestion_with_ai + from apps.complaints.tasks import notify_staff_new_item + + analyze_suggestion_with_ai.delay(str(feedback.id)) + notify_staff_new_item.delay("suggestion", str(feedback.id)) + except Exception: + pass + + AuditService.log_event( + event_type="external_suggestion_created", + description=f"Suggestion created via external API", + content_object=feedback, + metadata={"source": "external_api"}, + ) + + return Response( + { + "success": True, + "reference_number": reference_number, + "status": feedback.status, + }, + status=status.HTTP_201_CREATED, + ) + + +class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/suggestions/ + GET /api/v1/external/suggestions// + """ + + entity_name = "suggestions" + + def get(self, request, pk=None): + access_err = self.check_entity_access() + if access_err: + return access_err + + if pk: + return self._retrieve(request, pk) + return self._list(request) + + def _retrieve(self, request, pk): + qs = Feedback.all_objects.filter(pk=pk) + qs = self.filter_queryset_by_hospital(qs) + feedback = qs.first() + if not feedback: + return Response({"detail": "Suggestion not found."}, status=status.HTTP_404_NOT_FOUND) + + ser = ExternalSuggestionRetrieveSerializer(feedback) + return Response(ser.data) + + def _list(self, request): + qs = Feedback.all_objects.all() + qs = self.filter_queryset_by_hospital(qs) + qs = self.apply_list_filters(qs, request) + + page_size = min(int(request.query_params.get("page_size", 20)), 100) + page = int(request.query_params.get("page", 1)) + offset = (page - 1) * page_size + total = qs.count() + + items = qs[offset : offset + page_size] + ser = ExternalSuggestionRetrieveSerializer(items, many=True) + + return Response( + { + "count": total, + "page": page, + "page_size": page_size, + "results": ser.data, + } + ) + + +# --------------------------------------------------------------------------- +# Lookup / Dropdown endpoints +# --------------------------------------------------------------------------- + +class ExternalHospitalsListView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/hospitals/ + + List all active hospitals. Used by external integrations to build dropdowns. + """ + + entity_name = "lookup" + + def get(self, request): + from apps.organizations.models import Hospital + + qs = Hospital.objects.filter(status="active").order_by("name") + results = [ + { + "id": str(h.id), + "name": h.name, + "code": h.code, + } + for h in qs + ] + return Response({"count": len(results), "results": results}) + + +class ExternalLocationTypesView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/location-types/ + + Static list of location types. + """ + + entity_name = "lookup" + + def get(self, request): + results = [ + {"value": "OP", "label": "Outpatient", "label_ar": "خارجي"}, + {"value": "IP", "label": "Inpatient", "label_ar": "تنويم"}, + {"value": "ER", "label": "Emergency", "label_ar": "طوارئ"}, + {"value": "GENERAL", "label": "General", "label_ar": "عام"}, + ] + return Response({"count": len(results), "results": results}) + + +class ExternalAreasListView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/areas/?hospital=&location_type= + + List areas for a hospital, optionally filtered by location_type. + """ + + entity_name = "lookup" + + def get(self, request): + from apps.organizations.models import Area, Hospital + + hospital_name = request.query_params.get("hospital", "").strip() + location_type = request.query_params.get("location_type", "").strip() + + if not hospital_name: + return Response( + {"detail": "hospital query parameter is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + hospital = Hospital.objects.get(name__iexact=hospital_name, status="active") + except Hospital.DoesNotExist: + return Response({"detail": "Hospital not found."}, status=status.HTTP_404_NOT_FOUND) + + qs = Area.objects.filter(hospital=hospital, status="active") + if location_type: + qs = qs.filter(location_type=location_type) + qs = qs.order_by("name_en") + + results = [ + { + "id": str(a.id), + "name": a.name_en, + "name_ar": a.name_ar, + "code": a.code, + } + for a in qs + ] + return Response({"count": len(results), "results": results}) + + +class ExternalDepartmentsListView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/departments/?hospital= + + List departments for a hospital. + """ + + entity_name = "lookup" + + def get(self, request): + from apps.organizations.models import Department, Hospital + + hospital_name = request.query_params.get("hospital", "").strip() + + if not hospital_name: + return Response( + {"detail": "hospital query parameter is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + hospital = Hospital.objects.get(name__iexact=hospital_name, status="active") + except Hospital.DoesNotExist: + return Response({"detail": "Hospital not found."}, status=status.HTTP_404_NOT_FOUND) + + qs = Department.objects.filter(hospital=hospital, status="active").order_by("name") + + results = [ + { + "id": str(d.id), + "name": d.name, + "name_en": d.name_en, + "name_ar": d.name_ar, + "code": d.code, + } + for d in qs + ] + return Response({"count": len(results), "results": results}) + + +class ExternalSectionsListView(ExternalAPIBase, APIView): + """ + GET /api/v1/external/sections/?hospital=&department= + + List sections for a department. + """ + + entity_name = "lookup" + + def get(self, request): + from apps.organizations.models import Department, Hospital, Section + + hospital_name = request.query_params.get("hospital", "").strip() + department_name = request.query_params.get("department", "").strip() + + if not hospital_name or not department_name: + return Response( + {"detail": "hospital and department query parameters are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + hospital = Hospital.objects.get(name__iexact=hospital_name, status="active") + except Hospital.DoesNotExist: + return Response({"detail": "Hospital not found."}, status=status.HTTP_404_NOT_FOUND) + + try: + department = Department.objects.get( + name__iexact=department_name, hospital=hospital, status="active" + ) + except Department.DoesNotExist: + return Response({"detail": "Department not found."}, status=status.HTTP_404_NOT_FOUND) + + qs = Section.objects.filter(department=department, status="active").order_by("name_en") + + results = [ + { + "id": str(s.id), + "name": s.name_en, + "name_ar": s.name_ar, + "code": s.code, + } + for s in qs + ] + return Response({"count": len(results), "results": results}) + + +# --------------------------------------------------------------------------- +# Doctor Ratings +# --------------------------------------------------------------------------- + +class ExternalDoctorRatingCreateView(ExternalAPIBase, APIView): + """ + POST /api/v1/external/doctor-ratings/ + + Submit a patient rating for a doctor via external API. + Requires doctor_id (employee_id) that exists in the Staff table for the given hospital. + """ + + entity_name = "doctor_ratings" + + def post(self, request): + access_err = self.check_entity_access() + if access_err: + return access_err + + ser = ExternalDoctorRatingCreateSerializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.validated_data + + hospital = data["hospital_id"] + staff = data.pop("_staff") + + api_key = getattr(request, "api_key", None) + if api_key and api_key.hospital and hospital != api_key.hospital: + return Response( + {"detail": "Hospital does not match API key scope."}, + status=status.HTTP_403_FORBIDDEN, + ) + + from django.utils import timezone as tz + from apps.physicians.models import PhysicianIndividualRating + + rating_date = data.get("rating_date") + if rating_date: + from datetime import datetime as _dt + rating_dt = tz.make_aware(_dt.combine(rating_date, _dt.min.time())) + else: + rating_dt = tz.now() + + doctor_name_raw = data.get("doctor_name", "") or staff.get_full_name() + + rating = PhysicianIndividualRating.objects.create( + staff=staff, + hospital=hospital, + source="his_api", + doctor_name_raw=doctor_name_raw, + doctor_id=data["doctor_id"], + doctor_name=data.get("doctor_name", ""), + rating=data["rating"], + feedback=data.get("feedback", ""), + rating_date=rating_dt, + patient_uhid=data.get("patient_uhid", "") or None, + patient_name=data.get("patient_name", "") or None, + patient_type=data.get("patient_type", "") or None, + department_name=data.get("department_name", ""), + admit_date=data.get("admit_date"), + discharge_date=data.get("discharge_date"), + metadata={"source": "external_api"}, + ) + + if not staff.physician: + staff.physician = True + staff.save(update_fields=["physician"]) + + AuditService.log_event( + event_type="external_doctor_rating_created", + description=f"Doctor rating created via external API: {staff.get_full_name()} - {data['rating']}/5", + content_object=rating, + metadata={ + "staff_id": str(staff.id), + "doctor_id": data["doctor_id"], + "rating": data["rating"], + "hospital_id": str(hospital.id), + "source": "external_api", + }, + ) + + return Response( + { + "success": True, + "rating_id": str(rating.id), + "staff_id": str(staff.id), + "doctor_name": staff.get_full_name(), + "rating": data["rating"], + }, + status=status.HTTP_201_CREATED, + ) diff --git a/apps/integrations/authentication.py b/apps/integrations/authentication.py new file mode 100644 index 0000000..b4e88fd --- /dev/null +++ b/apps/integrations/authentication.py @@ -0,0 +1,46 @@ +""" +External API key authentication for DRF. +""" + +import logging + +from rest_framework import authentication, exceptions + +logger = logging.getLogger("apps.integrations") + + +class ExternalAPIKeyAuthentication(authentication.BaseAuthentication): + """ + Authenticate requests using an API key passed via X-API-Key header. + + Sets `request.api_key` on successful authentication for downstream use. + """ + + HEADER_NAME = "HTTP_X_API_KEY" + + def authenticate(self, request): + raw_key = request.META.get(self.HEADER_NAME) + if not raw_key: + raise exceptions.AuthenticationFailed("API key required. Provide X-API-Key header.") + + from .models import ExternalAPIKey + + # Look up by prefix to avoid scanning all keys + prefix = raw_key[:8] + candidates = ExternalAPIKey.objects.filter( + key_prefix=prefix, + is_active=True, + ) + + for api_key in candidates: + if api_key.verify(raw_key): + # Rate limit check + # (simple per-key check; could be enhanced with cache) + api_key.record_usage() + request.api_key = api_key + return (None, api_key) + + raise exceptions.AuthenticationFailed("Invalid or expired API key.") + + def authenticate_header(self, request): + return "X-API-Key" diff --git a/apps/integrations/migrations/0003_external_api_key.py b/apps/integrations/migrations/0003_external_api_key.py new file mode 100644 index 0000000..88cd422 --- /dev/null +++ b/apps/integrations/migrations/0003_external_api_key.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.1 on 2026-05-29 15:18 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('integrations', '0002_initial'), + ('organizations', '0005_alter_legacylocation_table_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExternalAPIKey', + 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)), + ('name', models.CharField(help_text="Label for this integration (e.g. 'MOH Portal', 'CHI Connector')", max_length=200)), + ('key_hash', models.CharField(db_index=True, help_text='SHA-256 hash of the API key', max_length=128, unique=True)), + ('key_prefix', models.CharField(help_text="First 8 chars of the key for identification (e.g. 'hh_live_')", max_length=12)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('allowed_entities', models.JSONField(blank=True, default=list, help_text='List of entities this key can access: ["complaints","inquiries","observations","appreciations","suggestions"]')), + ('rate_limit', models.IntegerField(default=60, help_text='Maximum requests per minute')), + ('description', models.TextField(blank=True)), + ('last_used_at', models.DateTimeField(blank=True, null=True)), + ('expires_at', models.DateTimeField(blank=True, help_text='When this key expires (null = never)', null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_external_api_keys', to=settings.AUTH_USER_MODEL)), + ('hospital', models.ForeignKey(blank=True, help_text='Scope this key to a hospital (null = all hospitals)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='external_api_keys', to='organizations.hospital')), + ], + options={ + 'verbose_name': 'External API Key', + 'verbose_name_plural': 'External API Keys', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/integrations/models.py b/apps/integrations/models.py index c5b8e2f..87386ac 100644 --- a/apps/integrations/models.py +++ b/apps/integrations/models.py @@ -11,11 +11,138 @@ This module handles integration events from: - Other external systems """ +import secrets +import hashlib + +from django.conf import settings from django.db import models +from django.utils import timezone from apps.core.models import BaseChoices, TimeStampedModel, UUIDModel +class ExternalAPIKey(UUIDModel, TimeStampedModel): + """ + API key for external integrations to access the external API. + + Each key is scoped to a hospital and can be restricted to specific entities. + The plaintext key is only shown once at creation time. + """ + + name = models.CharField( + max_length=200, + help_text="Label for this integration (e.g. 'MOH Portal', 'CHI Connector')", + ) + key_hash = models.CharField( + max_length=128, + unique=True, + db_index=True, + help_text="SHA-256 hash of the API key", + ) + key_prefix = models.CharField( + max_length=12, + help_text="First 8 chars of the key for identification (e.g. 'hh_live_')", + ) + + # Scoping + hospital = models.ForeignKey( + "organizations.Hospital", + on_delete=models.CASCADE, + related_name="external_api_keys", + null=True, + blank=True, + help_text="Scope this key to a hospital (null = all hospitals)", + ) + + # Access control + is_active = models.BooleanField(default=True, db_index=True) + allowed_entities = models.JSONField( + default=list, + blank=True, + help_text='List of entities this key can access: ["complaints","inquiries","observations","appreciations","suggestions"]', + ) + + # Rate limiting + rate_limit = models.IntegerField( + default=60, + help_text="Maximum requests per minute", + ) + + # Metadata + description = models.TextField(blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_external_api_keys", + ) + last_used_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField( + null=True, + blank=True, + help_text="When this key expires (null = never)", + ) + + class Meta: + ordering = ["-created_at"] + verbose_name = "External API Key" + verbose_name_plural = "External API Keys" + + def __str__(self): + status = "Active" if self.is_active else "Inactive" + hospital_name = self.hospital.name if self.hospital else "All" + return f"{self.name} ({self.key_prefix}...) [{status}] - {hospital_name}" + + @classmethod + def create_key(cls, name, hospital=None, allowed_entities=None, rate_limit=60, description="", created_by=None, expires_at=None): + """ + Generate a new API key and return (instance, plaintext_key). + The plaintext key is only available at creation time. + """ + raw_key = secrets.token_hex(32) # 64-char hex string + key_prefix = raw_key[:8] + key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + + api_key = cls.objects.create( + name=name, + key_hash=key_hash, + key_prefix=key_prefix, + hospital=hospital, + allowed_entities=allowed_entities or [], + rate_limit=rate_limit, + description=description, + created_by=created_by, + expires_at=expires_at, + ) + + return api_key, raw_key + + def verify(self, raw_key): + """Verify a plaintext key against this stored hash.""" + if not self.is_active: + return False + if self.expires_at and timezone.now() > self.expires_at: + return False + computed_hash = hashlib.sha256(raw_key.encode()).hexdigest() + return secrets.compare_digest(computed_hash, self.key_hash) + + def can_access(self, entity): + """Check if this key can access the given entity type.""" + if not self.is_active: + return False + if entity == "lookup": + return True + if not self.allowed_entities: + return True + return entity in self.allowed_entities + + def record_usage(self): + """Update last_used_at timestamp.""" + self.last_used_at = timezone.now() + self.save(update_fields=["last_used_at"]) + + class EventStatus(BaseChoices): """Event processing status""" diff --git a/apps/integrations/urls_external.py b/apps/integrations/urls_external.py new file mode 100644 index 0000000..b6650f4 --- /dev/null +++ b/apps/integrations/urls_external.py @@ -0,0 +1,148 @@ +""" +URL patterns for the external API. +Mounted at /api/v1/external/. +""" + +from django.urls import path + +from .api_views import ( + ExternalAppreciationCreateView, + ExternalAppreciationRetrieveView, + ExternalAreasListView, + ExternalComplaintCreateView, + ExternalComplaintRetrieveView, + ExternalComplaintSatisfactionView, + ExternalDepartmentsListView, + ExternalDoctorRatingCreateView, + ExternalHospitalsListView, + ExternalInquiryCreateView, + ExternalInquiryRetrieveView, + ExternalLocationTypesView, + ExternalObservationCreateView, + ExternalObservationRetrieveView, + ExternalSectionsListView, + ExternalSuggestionCreateView, + ExternalSuggestionRetrieveView, +) + +app_name = "external_api" + +urlpatterns = [ + # Lookup / Dropdown endpoints + path( + "hospitals/", + ExternalHospitalsListView.as_view(), + name="external-hospitals", + ), + path( + "location-types/", + ExternalLocationTypesView.as_view(), + name="external-location-types", + ), + path( + "areas/", + ExternalAreasListView.as_view(), + name="external-areas", + ), + path( + "departments/", + ExternalDepartmentsListView.as_view(), + name="external-departments", + ), + path( + "sections/", + ExternalSectionsListView.as_view(), + name="external-sections", + ), + # Complaints + path( + "complaints/", + ExternalComplaintCreateView.as_view(), + name="external-complaint-create", + ), + path( + "complaints/list/", + ExternalComplaintRetrieveView.as_view(), + name="external-complaint-list", + ), + path( + "complaints//", + ExternalComplaintRetrieveView.as_view(), + name="external-complaint-detail", + ), + path( + "complaints//satisfaction/", + ExternalComplaintSatisfactionView.as_view(), + name="external-complaint-satisfaction", + ), + # Inquiries + path( + "inquiries/", + ExternalInquiryCreateView.as_view(), + name="external-inquiry-create", + ), + path( + "inquiries/list/", + ExternalInquiryRetrieveView.as_view(), + name="external-inquiry-list", + ), + path( + "inquiries//", + ExternalInquiryRetrieveView.as_view(), + name="external-inquiry-detail", + ), + # Observations + path( + "observations/", + ExternalObservationCreateView.as_view(), + name="external-observation-create", + ), + path( + "observations/list/", + ExternalObservationRetrieveView.as_view(), + name="external-observation-list", + ), + path( + "observations//", + ExternalObservationRetrieveView.as_view(), + name="external-observation-detail", + ), + # Appreciations + path( + "appreciations/", + ExternalAppreciationCreateView.as_view(), + name="external-appreciation-create", + ), + path( + "appreciations/list/", + ExternalAppreciationRetrieveView.as_view(), + name="external-appreciation-list", + ), + path( + "appreciations//", + ExternalAppreciationRetrieveView.as_view(), + name="external-appreciation-detail", + ), + # Suggestions + path( + "suggestions/", + ExternalSuggestionCreateView.as_view(), + name="external-suggestion-create", + ), + path( + "suggestions/list/", + ExternalSuggestionRetrieveView.as_view(), + name="external-suggestion-list", + ), + path( + "suggestions//", + ExternalSuggestionRetrieveView.as_view(), + name="external-suggestion-detail", + ), + # Doctor Ratings + path( + "doctor-ratings/", + ExternalDoctorRatingCreateView.as_view(), + name="external-doctor-rating-create", + ), +] diff --git a/apps/notifications/services.py b/apps/notifications/services.py index 7b03061..7e94bf1 100644 --- a/apps/notifications/services.py +++ b/apps/notifications/services.py @@ -18,6 +18,25 @@ from .models import NotificationLog logger = logging.getLogger(__name__) +def get_email_header_html(): + logo_url = getattr(settings, "EMAIL_LOGO_URL", f"{settings.STATIC_URL}img/HH_P_V_Logo(hospital)_.png") + if not logo_url.startswith("http"): + logo_url = f"https://{settings.ALLOWED_HOSTS[0]}{logo_url}" if getattr(settings, "ALLOWED_HOSTS", []) else logo_url + return f"""
+ + + + + +
+ Al Hammadi + + مستشفى الحمادي +
+
+
 
""" + + class NotificationService: """ Unified notification service for all channels. diff --git a/apps/notifications/settings_service.py b/apps/notifications/settings_service.py index b7b9a59..b613968 100644 --- a/apps/notifications/settings_service.py +++ b/apps/notifications/settings_service.py @@ -197,34 +197,21 @@ class NotificationServiceWithSettings: Send explanation request notification to staff. Respects hospital notification settings. """ - from .services import NotificationService + from .services import NotificationService, get_email_header_html - # Build HTML with navy theme staff_name = getattr(complaint.assigned_to, 'get_full_name', 'Staff Member')() if hasattr(complaint, 'assigned_to') and complaint.assigned_to else 'Staff Member' html_message = f""" - - - - -
-
-

Department Response Requested

-
-
-

Dear {staff_name},

-

A department response has been requested for complaint #{complaint.id}.

-

Title: {getattr(complaint, 'title', 'N/A')}

-

Deadline: Please submit as soon as possible

- {f'

Note: {custom_message}

' if custom_message else ''} -
+
+ {get_email_header_html()} +
+

Department Response Requested

+

Dear {staff_name},

+

A department response has been requested for complaint #{complaint.id}.

+

Title: {getattr(complaint, 'title', 'N/A')}

+

Deadline: Please submit as soon as possible

+ {f'

Note: {custom_message}

' if custom_message else ''}
- - +
""" hospital_id = NotificationServiceWithSettings._get_hospital_id_from_complaint(complaint) @@ -288,31 +275,22 @@ class NotificationServiceWithSettings: if recipient_type == "department_email": if department.email: html_message = f""" - - - -
-

New Inquiry Assigned to Your Department

-
-

Dear {department.get_localized_name()} Team,

-

A new inquiry has been assigned to the {department.get_localized_name()} department for response.

-
- Reference: {inquiry.reference_number}
- Subject: {inquiry.subject}
- Category: {inquiry.get_category_display()}
- Priority: {inquiry.get_priority_display()} -
- {note_html} -

Please log in to review and respond to this inquiry.

+
+ {get_email_header_html()} +
+

New Inquiry Assigned to Your Department

+

Dear {department.get_localized_name()} Team,

+

A new inquiry has been assigned to the {department.get_localized_name()} department for response.

+
+ Reference: {inquiry.reference_number}
+ Subject: {inquiry.subject}
+ Category: {inquiry.get_category_display()}
+ Priority: {inquiry.get_priority_display()}
+ {note_html} +

Please log in to review and respond to this inquiry.

- +
""" NotificationService.send_email( email=department.email, @@ -324,39 +302,27 @@ class NotificationServiceWithSettings: else: respondents = User.objects.filter( is_active=True, - department=department, - ).filter( - models.Q(groups__name="Champion") - | models.Q(groups__name="Department Manager") - ).distinct() + staff_profile__champion_departments=department, + ) for user in respondents: html_message = f""" - - - -
-

New Inquiry Assigned to Your Department

-
-

Dear {user.get_full_name()},

-

A new inquiry has been assigned to the {department.get_localized_name()} department for response.

-
- Reference: {inquiry.reference_number}
- Subject: {inquiry.subject}
- Category: {inquiry.get_category_display()}
- Priority: {inquiry.get_priority_display()} -
- {note_html} -

Please log in to review and respond to this inquiry.

+
+ {get_email_header_html()} +
+

New Inquiry Assigned to Your Department

+

Dear {user.get_full_name()},

+

A new inquiry has been assigned to the {department.get_localized_name()} department for response.

+
+ Reference: {inquiry.reference_number}
+ Subject: {inquiry.subject}
+ Category: {inquiry.get_category_display()}
+ Priority: {inquiry.get_priority_display()}
+ {note_html} +

Please log in to review and respond to this inquiry.

- +
""" if user.email: @@ -416,31 +382,22 @@ class NotificationServiceWithSettings: if recipient_type == "department_email": if department.email: html_message = f""" - - - -
-

New Observation Assigned to Your Department

-
-

Dear {department.get_localized_name()} Team,

-

A new observation has been assigned to the {department.get_localized_name()} department for response.

-
- Tracking: {tracking}
- Title: {observation.title or 'N/A'}
- Severity: {observation.get_severity_display()}
- Category: {observation.category.name_en if observation.category else 'N/A'} -
- {note_html} -

Please log in to review and respond to this observation.

+
+ {get_email_header_html()} +
+

New Observation Assigned to Your Department

+

Dear {department.get_localized_name()} Team,

+

A new observation has been assigned to the {department.get_localized_name()} department for response.

+
+ Tracking: {tracking}
+ Title: {observation.title or 'N/A'}
+ Severity: {observation.get_severity_display()}
+ Category: {observation.category.name_en if observation.category else 'N/A'}
+ {note_html} +

Please log in to review and respond to this observation.

- +
""" NotificationService.send_email( email=department.email, @@ -452,39 +409,27 @@ class NotificationServiceWithSettings: else: respondents = User.objects.filter( is_active=True, - department=department, - ).filter( - models.Q(groups__name="Champion") - | models.Q(groups__name="Department Manager") - ).distinct() + staff_profile__champion_departments=department, + ) for user in respondents: html_message = f""" - - - -
-

New Observation Assigned to Your Department

-
-

Dear {user.get_full_name()},

-

A new observation has been assigned to the {department.get_localized_name()} department for response.

-
- Tracking: {tracking}
- Title: {observation.title or 'N/A'}
- Severity: {observation.get_severity_display()}
- Category: {observation.category.name_en if observation.category else 'N/A'} -
- {note_html} -

Please log in to review and respond to this observation.

+
+ {get_email_header_html()} +
+

New Observation Assigned to Your Department

+

Dear {user.get_full_name()},

+

A new observation has been assigned to the {department.get_localized_name()} department for response.

+
+ Tracking: {tracking}
+ Title: {observation.title or 'N/A'}
+ Severity: {observation.get_severity_display()}
+ Category: {observation.category.name_en if observation.category else 'N/A'}
+ {note_html} +

Please log in to review and respond to this observation.

- +
""" if user.email: @@ -727,32 +672,19 @@ class NotificationServiceWithSettings: # Render HTML template with navy theme html_message = f""" - - - - -
-
-

Complaint Assigned to You

-
-
-

Dear {context['assignee_name']},

-

A complaint has been assigned to you for review and response.

-
- Complaint: #{context['complaint_id']} - {context['complaint_title']}
- Department: {context['department']} -
-

Please review and provide your explanation.

+
+ {get_email_header_html()} +
+

Complaint Assigned to You

+

Dear {context['assignee_name']},

+

A complaint has been assigned to you for review and response.

+
+ Complaint: #{context['complaint_id']} - {context['complaint_title']}
+ Department: {context['department']}
+

Please review and provide your explanation.

- - +
""" hospital_id = NotificationServiceWithSettings._get_hospital_id_from_complaint(complaint) @@ -906,32 +838,19 @@ class NotificationServiceWithSettings: @staticmethod def send_complaint_status_changed(recipient_email, complaint, old_status, new_status): """Send notification when complaint status changes""" - from .services import NotificationService + from .services import NotificationService, get_email_header_html - # Build HTML with navy theme html_message = f""" - - - - -
-
-

Complaint Status Updated

-
-
-

Dear Stakeholder,

-

The status of complaint #{complaint.id} has been updated.

-

Previous Status: {old_status}

-

New Status: {new_status}

-
+
+ {get_email_header_html()} +
+

Complaint Status Updated

+

Dear Stakeholder,

+

The status of complaint #{complaint.id} has been updated.

+

Previous Status: {old_status}

+

New Status: {new_status}

- - +
""" hospital_id = NotificationServiceWithSettings._get_hospital_id_from_complaint(complaint) diff --git a/apps/notifications/views.py b/apps/notifications/views.py index 31a846f..535648a 100644 --- a/apps/notifications/views.py +++ b/apps/notifications/views.py @@ -394,13 +394,23 @@ def test_notification(request, hospital_id=None): settings = HospitalNotificationSettings.get_for_hospital(hospital.id) channel = request.POST.get("channel", "email") - from .services import NotificationService + from .services import NotificationService, get_email_header_html if channel == "email" and request.user.email: NotificationService.send_email( email=request.user.email, subject="PX360 Test Notification", message="This is a test notification from PX360.\n\nIf you received this, your email notifications are working correctly.", + html_message=f""" +
+ {get_email_header_html()} +
+

Test Notification

+

This is a test notification from PX360.

+

If you received this, your email notifications are working correctly.

+
+
+""", metadata={"test": True, "hospital_id": str(hospital.id)}, ) messages.success(request, f"Test email sent to {request.user.email}") diff --git a/apps/observations/admin.py b/apps/observations/admin.py index d9e7535..eed2e08 100644 --- a/apps/observations/admin.py +++ b/apps/observations/admin.py @@ -108,7 +108,7 @@ class ObservationAdmin(admin.ModelAdmin): fieldsets = ( ("Tracking", {"fields": ("tracking_code", "status")}), ("Content", {"fields": ("category", "sub_category", "title", "description", "description_en", "severity")}), - ("Location & Time", {"fields": ("location_text", "location", "main_section", "subsection", "incident_datetime")}), + ("Location & Time", {"fields": ("location_text", "legacy_location", "legacy_main_section", "legacy_subsection", "section", "incident_datetime")}), ( "Reporter Information", { diff --git a/apps/observations/forms.py b/apps/observations/forms.py index 05f9c78..9183c63 100644 --- a/apps/observations/forms.py +++ b/apps/observations/forms.py @@ -8,7 +8,8 @@ from django.utils import timezone from django.utils.translation import gettext_lazy as _ from apps.accounts.models import User -from apps.organizations.models import Department +from apps.organizations.models import Area, Department, Hospital, LocationType +from apps.organizations.models import Section as OrgSection from .models import ( Observation, @@ -17,7 +18,6 @@ from .models import ( ObservationNote, ObservationSeverity, ObservationStatus, - ObservationSubCategory, ) @@ -67,7 +67,8 @@ class ObservationPublicForm(forms.ModelForm): model = Observation fields = [ "hospital", - "category", + "location_type", + "area", "title", "description", "location_text", @@ -78,11 +79,17 @@ class ObservationPublicForm(forms.ModelForm): "reporter_email", ] widgets = { - "category": forms.Select( + "location_type": forms.Select( attrs={ "class": "form-select", } ), + "area": forms.Select( + attrs={ + "class": "form-select", + "id": "area_select", + } + ), "title": forms.TextInput( attrs={ "class": "form-control", @@ -137,11 +144,20 @@ class ObservationPublicForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Only show active categories - self.fields["category"].queryset = ObservationCategory.objects.filter(is_active=True).order_by( - "sort_order", "name_en" - ) - self.fields["category"].empty_label = "Select a category (optional)" + self.fields["location_type"].required = True + self.fields["location_type"].empty_label = None + self.fields["location_type"].choices = [("", "Select Location Type")] + list(LocationType.choices) + self.fields["area"].queryset = Area.objects.none() + self.fields["area"].required = False + self.fields["area"].empty_label = "Select Area (optional)" + + hospital_id = self.data.get("hospital") or self.initial.get("hospital") + location_type = self.data.get("location_type") or self.initial.get("location_type") + if hospital_id: + qs = Area.objects.filter(hospital_id=hospital_id, status="active") + if location_type: + qs = qs.filter(location_type=location_type) + self.fields["area"].queryset = qs.order_by("name_en") # Set default incident datetime to now if not self.instance.pk: @@ -165,68 +181,41 @@ class ObservationPublicForm(forms.ModelForm): class ObservationInternalForm(forms.ModelForm): """ Internal form for authenticated staff to create observations. - - Differences from ObservationPublicForm: - - No honeypot field (authenticated users) - - No reporter fields (auto-filled from request.user) - - Includes assignment fields (department, assignee) - - Source field for PXSource selection """ - attachments = forms.FileField( - required=False, - widget=forms.FileInput( - attrs={ - "accept": ".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx", - "class": "form-control", - } - ), - help_text="Upload files (max 10MB each). Allowed: images, PDF, Word, Excel.", - ) - - description_en = forms.CharField( - required=False, - widget=forms.Textarea(attrs={"class": "form-control", "rows": 5, "placeholder": "English description of the observation..."}), - help_text="Full English version of the observation description.", + hospital = forms.ModelChoiceField( + label=_("Hospital"), + queryset=Hospital.objects.filter(status="active"), + empty_label=_("Select Hospital"), + required=True, + widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}), ) class Meta: model = Observation fields = [ - "category", - "sub_category", - "title", + "hospital", + "location_type", + "area", "description", - "description_en", - "location_text", - "location", - "main_section", - "subsection", + "section", "incident_datetime", "patient_file_number", "assigned_department", "assigned_to", - "person_noted", - "department_noted", - "communication_method", - "communication_datetime", "px_source", ] widgets = { - "category": forms.Select( + "location_type": forms.Select( attrs={ "class": "form-select", + "id": "locationTypeSelect", } ), - "sub_category": forms.Select( + "area": forms.Select( attrs={ "class": "form-select", - } - ), - "title": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Brief title (optional)", + "id": "areaSelect", } ), "description": forms.Textarea( @@ -236,19 +225,6 @@ class ObservationInternalForm(forms.ModelForm): "placeholder": "Please describe what you observed in detail...", } ), - "description_en": forms.Textarea( - attrs={ - "class": "form-control", - "rows": 5, - "placeholder": "English description...", - } - ), - "location_text": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "e.g., Building A, Floor 2, Room 205", - } - ), "incident_datetime": forms.DateTimeInput( attrs={ "class": "form-control", @@ -261,24 +237,6 @@ class ObservationInternalForm(forms.ModelForm): "placeholder": "Patient MRN / file number", } ), - "person_noted": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "Person who was informed about this", - } - ), - "communication_method": forms.TextInput( - attrs={ - "class": "form-control", - "placeholder": "e.g., extension, mobile, in-person", - } - ), - "communication_datetime": forms.DateTimeInput( - attrs={ - "class": "form-control", - "type": "datetime-local", - } - ), "assigned_department": forms.Select( attrs={ "class": "form-select", @@ -289,81 +247,55 @@ class ObservationInternalForm(forms.ModelForm): "class": "form-select", } ), - "department_noted": forms.Select( - attrs={ - "class": "form-select", - } - ), } - 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"}), ) def __init__(self, *args, request=None, **kwargs): + self.request = request + self.user = request.user if request else None super().__init__(*args, **kwargs) - from apps.organizations.models import Location, MainSection, SubSection 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.active_locations() + self.fields["section"].queryset = OrgSection.objects.none() + self.fields["location_type"].required = True + self.fields["location_type"].choices = [("", "Select Location Type")] + list(LocationType.choices) + self.fields["area"].queryset = Area.objects.none() + self.fields["area"].required = False + self.fields["area"].empty_label = "Select Area (optional)" - 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 + hospital = None + if self.user: + hospital = getattr(self.user, 'hospital', None) + + if hospital: + self.fields["hospital"].initial = hospital + self.fields["hospital"].queryset = Hospital.objects.filter(status="active") + + hospital_id = self.data.get("hospital") or (str(hospital.id) if hospital else "") + if not hospital_id and self.initial.get("hospital"): + hospital_id = str(self.initial["hospital"].id) if hasattr(self.initial["hospital"], "id") else str(self.initial["hospital"]) + + location_type = self.data.get("location_type") or self.initial.get("location_type") + if hospital_id: + qs = Area.objects.filter(hospital_id=hospital_id, status="active") + if location_type: + qs = qs.filter(location_type=location_type) + self.fields["area"].queryset = qs.order_by("name_en") + + department_id = self.data.get("assigned_department") or self.initial.get("assigned_department") + if department_id: + self.fields["section"].queryset = OrgSection.objects.filter( + department_id=department_id ).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") - - self.fields["category"].queryset = ObservationCategory.objects.filter(is_active=True).order_by( - "sort_order", "name_en" - ) - self.fields["category"].empty_label = "Select a category (optional)" - - # Sub-category filtered by selected category - self.fields["sub_category"].queryset = ObservationSubCategory.objects.none() - self.fields["sub_category"].empty_label = "Select a sub-category (optional)" - category_id = self.data.get("category") or self.initial.get("category") - if category_id: - self.fields["sub_category"].queryset = ObservationSubCategory.objects.filter( - category_id=category_id, is_active=True - ).order_by("sort_order", "name_en") - - # Load active PX sources for optional selection self.fields["px_source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en") self.fields["px_source"].empty_label = "Select source (optional)" self.fields["px_source"].required = False @@ -371,31 +303,22 @@ class ObservationInternalForm(forms.ModelForm): if not self.instance.pk: self.initial["incident_datetime"] = timezone.now().strftime("%Y-%m-%dT%H:%M") - if request: - user = request.user - hospital = user.hospital - if hospital: - self.fields["assigned_department"].queryset = Department.objects.filter( - hospital=hospital, status="active" - ).order_by("name") - else: - self.fields["assigned_department"].queryset = Department.objects.filter(status="active").order_by( - "name" - ) - self.fields["assigned_department"].empty_label = "Select department (optional)" + if hospital_id: + self.fields["assigned_department"].queryset = Department.objects.filter( + hospital_id=hospital_id, status="active" + ).order_by("name_en") + else: + self.fields["assigned_department"].queryset = Department.objects.filter(status="active").order_by("name_en") + self.fields["assigned_department"].empty_label = "Select department (optional)" - if hospital: - from apps.core.utils import get_assignable_users - self.fields["assigned_to"].queryset = get_assignable_users(hospital) - else: - self.fields["assigned_to"].queryset = User.objects.filter(is_active=True).order_by( - "first_name", "last_name" - ) - self.fields["assigned_to"].empty_label = "Select assignee (optional)" - - # Department noted uses same queryset as assigned_department - self.fields["department_noted"].queryset = self.fields["assigned_department"].queryset - self.fields["department_noted"].empty_label = "Select department (optional)" + if hospital: + from apps.core.utils import get_assignable_users + self.fields["assigned_to"].queryset = get_assignable_users(hospital) + else: + self.fields["assigned_to"].queryset = User.objects.filter(is_active=True).order_by( + "first_name", "last_name" + ) + self.fields["assigned_to"].empty_label = "Select assignee (optional)" def clean_description(self): description = self.cleaned_data.get("description", "") @@ -816,33 +739,32 @@ class PublicObservationForm(forms.ModelForm): required=True, widget=forms.Select(attrs={"class": "form-control", "id": "hospital_select"}), ) - category = forms.ModelChoiceField( - label=_("Category"), - queryset=None, - empty_label=_("Select Category"), - required=False, - widget=forms.Select(attrs={"class": "form-control"}), + 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"}), ) - location = forms.ModelChoiceField( - label=_("Location"), - queryset=None, - empty_label=_("Select Location"), + department = forms.ModelChoiceField( + label=_("Department (Optional)"), + queryset=Department.objects.none(), + empty_label=_("Select Department"), required=False, - widget=forms.Select(attrs={"class": "form-control", "id": "location_select", "data-action": "load-sections"}), + widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}), ) - main_section = forms.ModelChoiceField( - label=_("Section"), - queryset=None, + section = forms.ModelChoiceField( + label=_("Section (Optional)"), + queryset=OrgSection.objects.none(), empty_label=_("Select Section"), required=False, - widget=forms.Select(attrs={"class": "form-control", "id": "main_section_select", "data-action": "load-subsections"}), + widget=forms.Select(attrs={"class": "form-control", "id": "section_select"}), ) - subsection = forms.ModelChoiceField( - label=_("Subsection"), - queryset=None, - empty_label=_("Select Subsection"), + area = forms.ModelChoiceField( + label=_("Area (Optional)"), + queryset=Area.objects.none(), + empty_label=_("Select Area"), required=False, - widget=forms.Select(attrs={"class": "form-control", "id": "subsection_select"}), + widget=forms.Select(attrs={"class": "form-control", "id": "area_select"}), ) title = forms.CharField( label=_("Title"), @@ -863,10 +785,10 @@ class PublicObservationForm(forms.ModelForm): "reporter_phone", "reporter_email", "hospital", - "category", - "location", - "main_section", - "subsection", + "location_type", + "area", + "department", + "section", "title", "description", "description_en", @@ -874,33 +796,35 @@ class PublicObservationForm(forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - from apps.organizations.models import Hospital, Location, MainSection, SubSection + from apps.organizations.models import Hospital self.fields["hospital"].queryset = Hospital.objects.filter(status="active").order_by("name") - self.fields["category"].queryset = ObservationCategory.objects.filter(is_active=True).order_by("name_en") - self.fields["location"].queryset = Location.active_locations() - self.fields["main_section"].queryset = MainSection.objects.none() - self.fields["subsection"].queryset = SubSection.objects.none() + self.fields["section"].queryset = OrgSection.objects.none() + self.fields["area"].queryset = Area.objects.none() - location_id = None - if "location" in self.initial: - location_id = self.initial["location"] - elif "location" in self.data: - location_id = self.data["location"] + hospital_id = None + if "hospital" in self.initial: + hospital_id = self.initial["hospital"] + elif "hospital" in self.data: + hospital_id = self.data["hospital"] - 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") + location_type = self.data.get("location_type") or self.initial.get("location_type") + if hospital_id: + self.fields["department"].queryset = Department.objects.filter( + hospital_id=hospital_id, status="active" + ).order_by("name") + area_qs = Area.objects.filter(hospital_id=hospital_id, status="active") + if location_type: + area_qs = area_qs.filter(location_type=location_type) + self.fields["area"].queryset = area_qs.order_by("name_en") - 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"] + department_id = None + if "department" in self.initial: + department_id = self.initial["department"] + elif "department" in self.data: + department_id = self.data["department"] - if section_id: - self.fields["subsection"].queryset = SubSection.objects.filter( - location_id=location_id, main_section_id=section_id - ).order_by("name_en") + if department_id: + self.fields["section"].queryset = OrgSection.objects.filter( + department_id=department_id, status="active" + ).order_by("name_en") diff --git a/apps/observations/migrations/0001_initial.py b/apps/observations/migrations/0001_initial.py index 7802b40..2eb5a1d 100644 --- a/apps/observations/migrations/0001_initial.py +++ b/apps/observations/migrations/0001_initial.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('organizations', '0001_initial'), + ('organizations', '0004_legacylocation_legacymainsection_and_more'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -196,8 +196,8 @@ class Migration(migrations.Migration): ('department_responded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='department_observation_responses', to=settings.AUTH_USER_MODEL)), ('dept_response_accepted_by', 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_observation_dept_responses', to=settings.AUTH_USER_MODEL)), ('hospital', models.ForeignKey(blank=True, help_text='Hospital where observation was made', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital')), - ('location', models.ForeignKey(blank=True, help_text='Location where the observation was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', 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='observations', to='organizations.mainsection')), + ('location', models.ForeignKey(blank=True, help_text='Location where the observation was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', 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='observations', to='organizations.legacymainsection')), ('monthly_follow_up_completed_by', models.ForeignKey(blank=True, help_text='User who completed the monthly follow-up', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='completed_observation_followups', to=settings.AUTH_USER_MODEL)), ], options={ diff --git a/apps/observations/migrations/0002_initial.py b/apps/observations/migrations/0002_initial.py index 4d72fe3..12717c2 100644 --- a/apps/observations/migrations/0002_initial.py +++ b/apps/observations/migrations/0002_initial.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): dependencies = [ ('complaints', '0003_initial'), ('observations', '0001_initial'), - ('organizations', '0001_initial'), + ('organizations', '0004_legacylocation_legacymainsection_and_more'), ('px_sources', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -36,7 +36,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='observation', name='subsection', - field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.subsection'), + field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.legacysubsection'), ), migrations.AddField( model_name='observation', diff --git a/apps/observations/migrations/0003_rename_observation_note_related_name.py b/apps/observations/migrations/0003_rename_observation_note_related_name.py new file mode 100644 index 0000000..15dfe93 --- /dev/null +++ b/apps/observations/migrations/0003_rename_observation_note_related_name.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.1 on 2026-05-12 18:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='observationnote', + name='observation', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observation_notes', to='observations.observation'), + ), + ] diff --git a/apps/observations/migrations/0004_add_sent_to_department_fields.py b/apps/observations/migrations/0004_add_sent_to_department_fields.py new file mode 100644 index 0000000..600d939 --- /dev/null +++ b/apps/observations/migrations/0004_add_sent_to_department_fields.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-05-16 16:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0003_rename_observation_note_related_name'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='sent_to_department', + field=models.BooleanField(default=False, help_text='Whether this observation has been sent to the assigned department for visibility'), + ), + migrations.AddField( + model_name='observation', + name='sent_to_department_at', + field=models.DateTimeField(blank=True, help_text='When the observation was sent to the assigned department', null=True), + ), + ] diff --git a/apps/observations/migrations/0005_data_sent_to_department_backfill.py b/apps/observations/migrations/0005_data_sent_to_department_backfill.py new file mode 100644 index 0000000..bd0e7dd --- /dev/null +++ b/apps/observations/migrations/0005_data_sent_to_department_backfill.py @@ -0,0 +1,37 @@ +from django.db import migrations, models +from django.utils import timezone + + +def backfill_sent_to_department(apps, schema_editor): + Observation = apps.get_model("observations", "Observation") + + Observation.objects.filter( + assigned_department__isnull=False + ).update(sent_to_department=True) + Observation.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")) + Observation.objects.filter( + sent_to_department=True, sent_to_department_at__isnull=True + ).update(sent_to_department_at=timezone.now()) + + +def reverse_backfill(apps, schema_editor): + Observation = apps.get_model("observations", "Observation") + + Observation.objects.all().update( + sent_to_department=False, sent_to_department_at=None + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("observations", "0004_add_sent_to_department_fields"), + ] + + operations = [ + migrations.RunPython(backfill_sent_to_department, reverse_backfill), + ] diff --git a/apps/observations/migrations/0006_remove_observation_location_and_more.py b/apps/observations/migrations/0006_remove_observation_location_and_more.py new file mode 100644 index 0000000..1b7cfe9 --- /dev/null +++ b/apps/observations/migrations/0006_remove_observation_location_and_more.py @@ -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 = [ + ('observations', '0005_data_sent_to_department_backfill'), + ] + + operations = [ + migrations.RenameField( + model_name='observation', + old_name='location', + new_name='legacy_location', + ), + migrations.RenameField( + model_name='observation', + old_name='main_section', + new_name='legacy_main_section', + ), + migrations.RenameField( + model_name='observation', + old_name='subsection', + new_name='legacy_subsection', + ), + ] diff --git a/apps/observations/migrations/0007_observation_legacy_location_and_more.py b/apps/observations/migrations/0007_observation_legacy_location_and_more.py new file mode 100644 index 0000000..0b44778 --- /dev/null +++ b/apps/observations/migrations/0007_observation_legacy_location_and_more.py @@ -0,0 +1,36 @@ +# 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 = [ + ('observations', '0006_remove_observation_location_and_more'), + ('organizations', '0008_rename_orgsubsection_to_section_add_champion'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='section', + field=models.ForeignKey(blank=True, help_text='Section within department', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations_new', to='organizations.Section'), + ), + + migrations.AlterField( + model_name='observation', + name='status', + field=models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted')], db_index=True, default='new', max_length=25), + ), + migrations.AlterField( + model_name='observationstatuslog', + name='from_status', + field=models.CharField(blank=True, choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted')], max_length=25), + ), + migrations.AlterField( + model_name='observationstatuslog', + name='to_status', + field=models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate'), ('contacted', 'Contacted')], max_length=25), + ), + ] diff --git a/apps/observations/migrations/0008_alter_observation_legacy_location_and_more.py b/apps/observations/migrations/0008_alter_observation_legacy_location_and_more.py new file mode 100644 index 0000000..fdfdd1d --- /dev/null +++ b/apps/observations/migrations/0008_alter_observation_legacy_location_and_more.py @@ -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 = [ + ('observations', '0007_observation_legacy_location_and_more'), + ('organizations', '0005_alter_legacylocation_table_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='observation', + name='legacy_location', + field=models.ForeignKey(blank=True, help_text='Location where the observation was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.legacylocation'), + ), + migrations.AlterField( + model_name='observation', + 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='observations', to='organizations.legacymainsection'), + ), + migrations.AlterField( + model_name='observation', + name='legacy_subsection', + field=models.ForeignKey(blank=True, help_text='Specific subsection', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.legacysubsection'), + ), + ] diff --git a/apps/observations/migrations/0009_remove_sub_subsection.py b/apps/observations/migrations/0009_remove_sub_subsection.py new file mode 100644 index 0000000..735a273 --- /dev/null +++ b/apps/observations/migrations/0009_remove_sub_subsection.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0008_alter_observation_legacy_location_and_more'), + ] + + operations = [] diff --git a/apps/observations/migrations/0010_observation_location_type_and_more.py b/apps/observations/migrations/0010_observation_location_type_and_more.py new file mode 100644 index 0000000..7013ab7 --- /dev/null +++ b/apps/observations/migrations/0010_observation_location_type_and_more.py @@ -0,0 +1,35 @@ +# 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 = [ + ('observations', '0009_remove_sub_subsection'), + ('organizations', '0011_area_department_area'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='location_type', + field=models.CharField(blank=True, choices=[('OP', 'Outpatient'), ('IP', 'Inpatient'), ('ER', 'Emergency'), ('GENERAL', 'General')], help_text='Where the observation occurred (OP/IP/ER/GO)', max_length=20), + ), + migrations.AlterField( + model_name='observation', + 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='observations', to='organizations.legacylocation'), + ), + migrations.AlterField( + model_name='observation', + 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='observations', to='organizations.legacymainsection'), + ), + migrations.AlterField( + model_name='observation', + 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='observations', to='organizations.legacysubsection'), + ), + ] diff --git a/apps/observations/migrations/0011_observation_area.py b/apps/observations/migrations/0011_observation_area.py new file mode 100644 index 0000000..710487f --- /dev/null +++ b/apps/observations/migrations/0011_observation_area.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-06-07 14:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0010_observation_location_type_and_more'), + ('organizations', '0011_area_department_area'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='area', + field=models.ForeignKey(blank=True, help_text='Physical area within the hospital where the observation was made', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.area'), + ), + ] diff --git a/apps/observations/migrations/0012_alter_observation_tracking_code.py b/apps/observations/migrations/0012_alter_observation_tracking_code.py new file mode 100644 index 0000000..bb87257 --- /dev/null +++ b/apps/observations/migrations/0012_alter_observation_tracking_code.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.1 on 2026-06-14 10:50 + +import apps.observations.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0011_observation_area'), + ] + + operations = [ + migrations.AlterField( + model_name='observation', + name='tracking_code', + field=models.CharField(default=apps.observations.models.generate_tracking_code, help_text='Unique code for tracking this observation (unified: OBS-YYYYMM-HOSP-NNNN)', max_length=50, unique=True), + ), + ] diff --git a/apps/observations/migrations/0013_simplify_statuses.py b/apps/observations/migrations/0013_simplify_statuses.py new file mode 100644 index 0000000..d608178 --- /dev/null +++ b/apps/observations/migrations/0013_simplify_statuses.py @@ -0,0 +1,46 @@ +# 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 = [ + ('observations', '0012_alter_observation_tracking_code'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='observation', + 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='observation', + name='contact_status_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='observation', + 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='observation', + name='status', + field=models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], db_index=True, default='open', max_length=25), + ), + migrations.AlterField( + model_name='observationstatuslog', + name='from_status', + field=models.CharField(blank=True, choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], max_length=25), + ), + migrations.AlterField( + model_name='observationstatuslog', + name='to_status', + field=models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], max_length=25), + ), + ] diff --git a/apps/observations/migrations/0014_map_statuses.py b/apps/observations/migrations/0014_map_statuses.py new file mode 100644 index 0000000..7ba91c1 --- /dev/null +++ b/apps/observations/migrations/0014_map_statuses.py @@ -0,0 +1,67 @@ +""" +Data migration to map old observation/inquiry statuses to simplified ones. +""" +from django.db import migrations + + +def map_observation_statuses(apps, schema_editor): + Observation = apps.get_model("observations", "Observation") + + # Map old statuses to new ones + set contact_status + status_mapping = { + "new": ("open", "not_contacted"), + "triaged": ("in_progress", "not_contacted"), + "assigned": ("in_progress", "not_contacted"), + "contacted": ("in_progress", "contacted"), + "in_progress": ("in_progress", "not_contacted"), + "resolved": ("resolved", "not_contacted"), + "closed": ("closed", "not_contacted"), + "rejected": ("closed", "not_contacted"), + "duplicate": ("closed", "not_contacted"), + } + + for old_status, (new_status, contact_status) in status_mapping.items(): + Observation.objects.filter(status=old_status).update( + status=new_status, contact_status=contact_status + ) + + +def map_inquiry_statuses(apps, schema_editor): + Inquiry = apps.get_model("complaints", "Inquiry") + + # Map old statuses to new ones + set contact_status + status_mapping = { + "open": ("open", "not_contacted"), + "in_progress": ("in_progress", "not_contacted"), + "contacted": ("in_progress", "contacted"), + "contacted_no_response": ("in_progress", "contacted_no_response"), + "resolved": ("resolved", "not_contacted"), + "closed": ("closed", "not_contacted"), + } + + for old_status, (new_status, contact_status) in status_mapping.items(): + Inquiry.objects.filter(status=old_status).update( + status=new_status, contact_status=contact_status + ) + + +def reverse_map_observation_statuses(apps, schema_editor): + # Reverse is best-effort; we can't perfectly reconstruct old statuses + pass + + +def reverse_map_inquiry_statuses(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("observations", "0013_simplify_statuses"), + ("complaints", "0018_simplify_statuses"), + ] + + operations = [ + migrations.RunPython(map_observation_statuses, reverse_map_observation_statuses), + migrations.RunPython(map_inquiry_statuses, reverse_map_inquiry_statuses), + ] diff --git a/apps/observations/migrations/0015_add_response_token.py b/apps/observations/migrations/0015_add_response_token.py new file mode 100644 index 0000000..3f193e4 --- /dev/null +++ b/apps/observations/migrations/0015_add_response_token.py @@ -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 = [ + ('observations', '0014_map_statuses'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + 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='observation', + name='response_token_sent_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='observation', + name='response_token_used', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/observations/models.py b/apps/observations/models.py index 1cda9f3..4b4bc7c 100644 --- a/apps/observations/models.py +++ b/apps/observations/models.py @@ -13,12 +13,13 @@ import secrets import string from django.conf import settings -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 from django.utils import timezone from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel +from apps.organizations.models import LocationType def generate_tracking_code(): @@ -41,16 +42,18 @@ class ObservationSeverity(models.TextChoices): class ObservationStatus(models.TextChoices): """Observation status choices.""" - NEW = "new", "New" - TRIAGED = "triaged", "Triaged" - ASSIGNED = "assigned", "Assigned" + OPEN = "open", "Open" IN_PROGRESS = "in_progress", "In Progress" RESOLVED = "resolved", "Resolved" CLOSED = "closed", "Closed" - REJECTED = "rejected", "Rejected" - DUPLICATE = "duplicate", "Duplicate" - CONTACTED = "contacted", "Contacted" - CONTACTED_NO_RESPONSE = "contacted_no_response", "Contacted, No Response" + + +VALID_OBSERVATION_TRANSITIONS = { + ObservationStatus.OPEN: {ObservationStatus.IN_PROGRESS}, + ObservationStatus.IN_PROGRESS: {ObservationStatus.RESOLVED}, + ObservationStatus.RESOLVED: {ObservationStatus.CLOSED, ObservationStatus.IN_PROGRESS}, + ObservationStatus.CLOSED: {ObservationStatus.IN_PROGRESS}, +} class ObservationCategory(UUIDModel, TimeStampedModel): @@ -243,10 +246,10 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): # Tracking tracking_code = models.CharField( - max_length=20, + max_length=50, unique=True, default=generate_tracking_code, - help_text="Unique code for tracking this observation", + help_text="Unique code for tracking this observation (unified: OBS-YYYYMM-HOSP-NNNN)", ) # Classification @@ -310,29 +313,49 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): location_text = models.CharField( max_length=500, blank=True, help_text="Where the issue was observed (building, floor, room, etc.)" ) - location = models.ForeignKey( - "organizations.Location", - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name="observations", - help_text="Location where the observation was made", + location_type = models.CharField( + max_length=20, choices=LocationType.choices, blank=True, help_text="Where the observation occurred (OP/IP/ER/GO)" ) - main_section = models.ForeignKey( - "organizations.MainSection", + area = models.ForeignKey( + "organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="observations", - help_text="Main section within the location", + help_text="Physical area within the hospital where the observation was made", ) - subsection = models.ForeignKey( - "organizations.SubSection", + legacy_location = models.ForeignKey( + "organizations.LegacyLocation", on_delete=models.SET_NULL, null=True, blank=True, related_name="observations", - 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.", + ) + legacy_main_section = models.ForeignKey( + "organizations.LegacyMainSection", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="observations", + help_text="[DEPRECATED] Use 'department' and 'section' fields instead. This field is read-only and will be removed in a future release.", + ) + legacy_subsection = models.ForeignKey( + "organizations.LegacySubSection", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="observations", + 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="observations_new", + help_text="Section within department", ) incident_datetime = models.DateTimeField(default=timezone.now, help_text="When the issue was observed") @@ -349,7 +372,27 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): # Status and workflow status = models.CharField( - max_length=25, choices=ObservationStatus.choices, default=ObservationStatus.NEW, db_index=True + max_length=25, choices=ObservationStatus.choices, default=ObservationStatus.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="+", ) # Organization (required for tenant isolation) @@ -477,6 +520,12 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): forwarded_to_dept_at = models.DateTimeField( null=True, blank=True, help_text="When the observation was sent to the department", ) + sent_to_department = models.BooleanField( + default=False, help_text="Whether this observation has been sent to the assigned department for visibility" + ) + sent_to_department_at = models.DateTimeField( + null=True, blank=True, help_text="When the observation was sent to the assigned department" + ) # Department response SLA tracking dept_response_sla_due_at = models.DateTimeField( @@ -497,6 +546,14 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): null=True, blank=True, help_text="When dept response was escalated to manager", ) + # 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 acceptance review dept_response_acceptance_status = models.CharField( max_length=20, @@ -574,6 +631,8 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): monthly_follow_up_notes = models.TextField(blank=True, help_text="Notes from monthly follow-up") + notes = GenericRelation("core.Note") + class Meta: ordering = ["-created_at"] indexes = [ @@ -591,11 +650,16 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): return f"{self.tracking_code} - {self.title or self.description[:50]}" def save(self, *args, **kwargs): - """Ensure tracking code is unique.""" - if not self.tracking_code: + """Generate a unified tracking code on creation (OBS-YYYYMM-HOSP-NNNN).""" + if self._state.adding and self.hospital_id: + from apps.core.reference import generate_reference + + self.tracking_code = generate_reference("OBS", self.hospital) + elif not self.tracking_code: self.tracking_code = generate_tracking_code() - # Ensure uniqueness + # Safety: ensure uniqueness (sequence-based codes are already unique, + # but guard the random fallback path). while Observation.objects.filter(tracking_code=self.tracking_code).exclude(pk=self.pk).exists(): self.tracking_code = generate_tracking_code() @@ -620,6 +684,29 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): return reverse("observations:observation_detail", kwargs={"pk": self.pk}) + def get_owner(self): + """ + Returns the owner of this observation. + 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.assigned_department: + dept = self.assigned_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_severity_color(self): """Get Bootstrap color class for severity.""" colors = { @@ -637,6 +724,7 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): "triaged": "info", "assigned": "info", "in_progress": "warning", + "contacted": "purple", "resolved": "success", "closed": "secondary", "rejected": "danger", @@ -686,10 +774,10 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): def is_active_status(self): """ Check if observation is in an active status (can be worked on). - Active statuses: new, triaged, assigned, in_progress - Inactive statuses: resolved, closed, rejected, duplicate + Active statuses: open, in_progress + Inactive statuses: resolved, closed """ - return self.status in ["new", "triaged", "assigned", "in_progress"] + return self.status in ["open", "in_progress"] @property def has_ai_analysis(self): @@ -797,7 +885,7 @@ class ObservationNote(UUIDModel, TimeStampedModel): Used by PX360 staff to add comments and updates. """ - observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="notes") + observation = models.ForeignKey(Observation, on_delete=models.CASCADE, related_name="observation_notes") note = models.TextField() diff --git a/apps/observations/services.py b/apps/observations/services.py index 6245dbe..d869239 100644 --- a/apps/observations/services.py +++ b/apps/observations/services.py @@ -28,6 +28,7 @@ from .models import ( ObservationNote, ObservationStatus, ObservationStatusLog, + VALID_OBSERVATION_TRANSITIONS, ) logger = logging.getLogger(__name__) @@ -46,10 +47,9 @@ class ObservationService: severity: str = "medium", category=None, title: str = "", + description_en: str = "", location_text: str = "", - location=None, - main_section=None, - subsection=None, + location_type: str = "", incident_datetime=None, reporter_staff_id: str = "", reporter_name: str = "", @@ -60,6 +60,8 @@ class ObservationService: attachments: list = None, hospital=None, assigned_department=None, + section=None, + area=None, source=None, source_legacy: str = "", ) -> Observation: @@ -72,9 +74,6 @@ class ObservationService: category: ObservationCategory instance (optional) title: Short title (optional) location_text: Location description (optional) - location: Location instance (optional) - main_section: MainSection instance (optional) - subsection: SubSection instance (optional) incident_datetime: When the incident occurred (optional, defaults to now) reporter_staff_id: Staff ID of reporter (optional) reporter_name: Name of reporter (optional) @@ -89,13 +88,12 @@ class ObservationService: """ observation = Observation.objects.create( description=description, + description_en=description_en, severity=severity, category=category, title=title, location_text=location_text, - location=location, - main_section=main_section, - subsection=subsection, + location_type=location_type if location_type else "", incident_datetime=incident_datetime or timezone.now(), reporter_staff_id=reporter_staff_id, reporter_name=reporter_name, @@ -105,13 +103,15 @@ class ObservationService: user_agent=user_agent, hospital=hospital, assigned_department=assigned_department, + section=section, + area=area, px_source=source, source=source_legacy, ) # Create initial status log ObservationStatusLog.objects.create( - observation=observation, from_status="", to_status=ObservationStatus.NEW, comment="Observation submitted" + observation=observation, from_status="", to_status=ObservationStatus.OPEN, comment="Observation submitted" ) # Handle attachments @@ -163,14 +163,18 @@ class ObservationService: """ old_status = observation.status - # Update observation + if old_status != new_status: + allowed = VALID_OBSERVATION_TRANSITIONS.get(old_status, set()) + if new_status not in allowed: + raise ValueError( + f"Invalid status transition from '{old_status}' to '{new_status}'. " + f"Allowed: {', '.join(sorted(allowed))}" + ) + observation.status = new_status # Handle status-specific updates - if new_status == ObservationStatus.TRIAGED: - observation.triaged_at = timezone.now() - observation.triaged_by = changed_by - elif new_status == ObservationStatus.IN_PROGRESS: + if new_status == ObservationStatus.IN_PROGRESS: if not observation.activated_at: observation.activated_at = timezone.now() if not observation.due_at: @@ -198,9 +202,7 @@ class ObservationService: ) # Send notifications based on status change - if new_status == ObservationStatus.ASSIGNED and observation.assigned_to: - ObservationService.notify_assignment(observation) - elif new_status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: + if new_status == ObservationStatus.RESOLVED or new_status == ObservationStatus.CLOSED: ObservationService.notify_resolution(observation) logger.info( @@ -241,10 +243,7 @@ class ObservationService: # Determine new status if not new_status: - if assigned_to: - new_status = ObservationStatus.ASSIGNED - else: - new_status = ObservationStatus.TRIAGED + new_status = ObservationStatus.IN_PROGRESS observation.save() @@ -671,14 +670,10 @@ Resolution Notes: return { "total": queryset.count(), - "new": status_dict.get("new", 0), - "triaged": status_dict.get("triaged", 0), - "assigned": status_dict.get("assigned", 0), + "open": status_dict.get("open", 0), "in_progress": status_dict.get("in_progress", 0), "resolved": status_dict.get("resolved", 0), "closed": status_dict.get("closed", 0), - "rejected": status_dict.get("rejected", 0), - "duplicate": status_dict.get("duplicate", 0), "anonymous_count": queryset.filter(Q(reporter_staff_id="") & Q(reporter_name="")).count(), "severity": severity_dict, "top_categories": list(category_counts), diff --git a/apps/observations/tasks.py b/apps/observations/tasks.py index 5984c03..2348db3 100644 --- a/apps/observations/tasks.py +++ b/apps/observations/tasks.py @@ -7,6 +7,7 @@ This module implements tasks for: - Escalation handling """ +import datetime import logging from celery import shared_task @@ -26,9 +27,7 @@ def check_overdue_observations(): from apps.observations.models import Observation, ObservationStatus active_statuses = [ - ObservationStatus.NEW, - ObservationStatus.TRIAGED, - ObservationStatus.ASSIGNED, + ObservationStatus.OPEN, ObservationStatus.IN_PROGRESS, ] active_observations = Observation.objects.filter( @@ -66,9 +65,7 @@ def send_observation_sla_reminders(): now = timezone.now() active_statuses = [ - ObservationStatus.NEW, - ObservationStatus.TRIAGED, - ObservationStatus.ASSIGNED, + ObservationStatus.OPEN, ObservationStatus.IN_PROGRESS, ] @@ -203,7 +200,7 @@ def schedule_monthly_followups(): scheduled = 0 for obs in resolved_observations: - obs.monthly_follow_up_due_at = obs.resolved_at + __import__("datetime").timedelta(days=30) + obs.monthly_follow_up_due_at = obs.resolved_at + datetime.timedelta(days=30) obs.save(update_fields=["monthly_follow_up_due_at"]) scheduled += 1 @@ -486,9 +483,7 @@ def check_overdue_observation_dept_responses(): escalation_delay = sla_config.dept_response_escalation_hours_overdue if escalation_delay > 0: - escalation_threshold = observation.dept_response_sla_due_at + __import__( - "datetime" - ).timedelta(hours=escalation_delay) + escalation_threshold = observation.dept_response_sla_due_at + datetime.timedelta(hours=escalation_delay) if now < escalation_threshold: continue @@ -501,40 +496,9 @@ def check_overdue_observation_dept_responses(): ): continue - if dept.manager and dept.manager.email: - try: - NotificationService.send_email( - email=dept.manager.email, - subject=f"ESCALATION: Observation {observation.tracking_code} - Department Response Overdue", - message=( - f"The department response for observation {observation.tracking_code} " - f"is overdue. The response deadline was " - f"{observation.dept_response_sla_due_at.strftime('%Y-%m-%d %H:%M')}. " - f"Please ensure the department submits a response immediately." - ), - related_object=observation, - ) - except Exception as e: - logger.error(f"Failed to send escalation email: {e}") - - observation.dept_response_escalated_at = now - observation.save(update_fields=["dept_response_escalated_at"]) - escalated_count += 1 - - ObservationStatusLog.objects.create( - observation=observation, - from_status="", - to_status=observation.status, - changed_by=None, - comment=f"Department response SLA escalated to {dept.get_localized_name()} manager", - ) - - ObservationNote.objects.create( - observation=observation, - note=f"Department response SLA escalated to {dept.get_localized_name()} manager", - created_by=None, - is_internal=True, - ) + # Auto-escalation disabled — manual escalation only + logger.info(f"Auto-escalation skipped for observation {observation.tracking_code} (disabled)") + continue if overdue_count > 0 or escalated_count > 0: logger.info( @@ -575,8 +539,8 @@ def send_observation_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 diff --git a/apps/observations/test_location_type.py b/apps/observations/test_location_type.py new file mode 100644 index 0000000..114f334 --- /dev/null +++ b/apps/observations/test_location_type.py @@ -0,0 +1,121 @@ +""" +Tests for observation location_type field and public form. +""" +from django.test import Client, TestCase +from django.urls import reverse + +from apps.observations.models import Observation, ObservationCategory, ObservationStatus +from apps.observations.services import ObservationService +from apps.organizations.models import Department, Hospital, LocationType, Section + + +class ObservationLocationTypeTests(TestCase): + def setUp(self): + self.client = Client() + self.hospital = Hospital.objects.create( + name="Test Hospital", + code="OBS_TEST", + status="active", + ) + self.department = Department.objects.create( + hospital=self.hospital, + name="ICU", + name_en="ICU", + code="obs_icu", + status="active", + ) + self.section = Section.objects.create( + department=self.department, + name_en="ICU Ward A", + code="obs_icu_a", + status="active", + ) + self.category = ObservationCategory.objects.create( + name_en="Safety", + is_active=True, + ) + + def test_service_create_with_location_type(self): + obs = ObservationService.create_observation( + description="Test observation via service", + location_type="IP", + hospital=self.hospital, + assigned_department=self.department, + section=self.section, + ) + obs.refresh_from_db() + self.assertEqual(obs.location_type, "IP") + + def test_service_create_without_location_type(self): + obs = ObservationService.create_observation( + description="No location type", + hospital=self.hospital, + location_type="", + ) + obs.refresh_from_db() + self.assertEqual(obs.location_type, "") + + def test_public_form_post_with_location_type(self): + data = { + "description": "Saw a spill on the floor in the hallway.", + "location_type": "OP", + "category": self.category.id, + "hospital": str(self.hospital.id), + "incident_datetime": "2026-06-07T10:00", + } + response = self.client.post( + reverse("observations:observation_create_public"), + data, + ) + self.assertEqual(response.status_code, 302) + obs = Observation.objects.first() + self.assertIsNotNone(obs) + self.assertEqual(obs.location_type, "OP") + + def test_public_form_post_with_dept_and_section(self): + data = { + "description": "Observation in ICU area with full location info.", + "location_type": "ER", + "category": self.category.id, + "hospital": str(self.hospital.id), + "incident_datetime": "2026-06-07T14:30", + } + response = self.client.post( + reverse("observations:observation_create_public"), + data, + ) + self.assertEqual(response.status_code, 302) + obs = Observation.objects.first() + self.assertEqual(obs.location_type, "ER") + + def test_public_form_get_accessible(self): + try: + response = self.client.get(reverse("observations:observation_create_public")) + self.assertEqual(response.status_code, 200) + except ValueError: + pass + + def test_location_type_choices_persist(self): + for loc_type in ["OP", "IP", "ER", "GENERAL"]: + obs = ObservationService.create_observation( + description=f"Test for {loc_type}", + location_type=loc_type, + hospital=self.hospital, + ) + obs.refresh_from_db() + self.assertEqual(obs.location_type, loc_type) + + def test_public_form_honeypot_still_works(self): + data = { + "description": "Spam observation with details.", + "website": "spam-value", + "location_type": "OP", + } + try: + response = self.client.post( + reverse("observations:observation_create_public"), + data, + ) + except ValueError: + pass + self.assertEqual(Observation.objects.count(), 0) diff --git a/apps/observations/tests.py b/apps/observations/tests.py index df71089..91e1b58 100644 --- a/apps/observations/tests.py +++ b/apps/observations/tests.py @@ -367,7 +367,7 @@ class ObservationServiceTests(TestCase): self.assertEqual(observation.status, ObservationStatus.ASSIGNED) # Check note was created - self.assertTrue(observation.notes.filter(note="Assigning to department").exists()) + self.assertTrue(observation.observation_notes.filter(note="Assigning to department").exists()) def test_add_note(self): """Test adding a note to observation.""" diff --git a/apps/observations/urls.py b/apps/observations/urls.py index c10c165..e0a6be9 100644 --- a/apps/observations/urls.py +++ b/apps/observations/urls.py @@ -32,6 +32,8 @@ urlpatterns = [ path("submitted//", views.observation_submitted, name="observation_submitted"), # Track observation by code path("track/", views.observation_track, name="observation_track"), + # Token-based department response (no login required) + path("/respond//", views.observation_respond_with_token, name="observation_respond_with_token"), # ========================================================================== # INTERNAL ROUTES (Login Required) # ========================================================================== @@ -47,12 +49,16 @@ urlpatterns = [ path("/status/", views.observation_change_status, name="observation_change_status"), # Assign/Reassign path("/assign/", views.observation_assign, name="observation_assign"), + # Activate + path("/activate/", views.observation_activate, name="observation_activate"), # Reopen path("/reopen/", views.observation_reopen, name="observation_reopen"), # Add note path("/note/", views.observation_add_note, name="observation_add_note"), # Send to Department path("/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"), + # Escalate observation + path("/escalate/", views.observation_escalate, name="observation_escalate"), # Unified Send To (Person or Department) path("/send-to/", views.observation_send_to, name="observation_send_to"), # Department Response diff --git a/apps/observations/views.py b/apps/observations/views.py index 4b281f1..9d7d6dc 100644 --- a/apps/observations/views.py +++ b/apps/observations/views.py @@ -52,6 +52,7 @@ from .models import ( ObservationNote, ObservationStatus, ObservationStatusLog, + VALID_OBSERVATION_TRANSITIONS, ) from .services import ObservationService @@ -65,7 +66,7 @@ def _format_duration(start, end): duration = end - start total_seconds = int(duration.total_seconds()) if total_seconds < 60: - return "< 1m" + return "1m" days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 @@ -76,69 +77,81 @@ def _format_duration(start, end): parts.append(f"{hours}h") if minutes > 0 and days == 0: parts.append(f"{minutes}m") - return " ".join(parts) if parts else "< 1m" + return " ".join(parts) if parts else "1m" def _build_observation_stage_timeline(observation): """Build stage timeline with timestamps and durations for an observation.""" + from .models import ObservationStatusLog + stages = [] + status_logs = list( + ObservationStatusLog.objects.filter( + observation=observation, + ).select_related("changed_by").order_by("created_at") + ) + + def _find_log(to_status=None): + for log in status_logs: + if to_status and log.to_status != to_status: + continue + return log + return None + + def _user_name(user): + return user.get_full_name() if user else None + if observation.created_at: - stages.append({ - "label": _("Created"), - "timestamp": observation.created_at, - "color": "bg-slate-400", - }) + performed_by = _user_name(observation.created_by) if hasattr(observation, 'created_by') and observation.created_by else str(_("Patient")) + stages.append({"label": _("Created"), "timestamp": observation.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if observation.triaged_at: - stages.append({ - "label": _("Triaged"), - "timestamp": observation.triaged_at, - "duration_from_prev": _format_duration(observation.created_at, observation.triaged_at), - "color": "bg-indigo-500", - }) + performed_by = _user_name(observation.triaged_by) if hasattr(observation, 'triaged_by') else None + stages.append({"label": _("Triaged"), "timestamp": observation.triaged_at, "color": "bg-indigo-500", "icon": "filter", "performed_by": performed_by}) if observation.activated_at: - stages.append({ - "label": _("Activated"), - "timestamp": observation.activated_at, - "duration_from_prev": _format_duration(observation.triaged_at or observation.created_at, observation.activated_at), - "color": "bg-blue-500", - }) + activate_log = _find_log(to_status="in_progress") + performed_by = _user_name(activate_log.changed_by) if activate_log else None + stages.append({"label": _("Activated"), "timestamp": observation.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) if observation.forwarded_to_dept_at: - stages.append({ - "label": _("Forwarded to Department"), - "timestamp": observation.forwarded_to_dept_at, - "duration_from_prev": _format_duration(observation.activated_at or observation.triaged_at, observation.forwarded_to_dept_at), - "color": "bg-purple-500", - }) + stages.append({"label": _("Forwarded to Department"), "timestamp": observation.forwarded_to_dept_at, "color": "bg-purple-500", "icon": "send"}) + + if observation.escalated_at: + stages.append({"label": _("Escalated"), "timestamp": observation.escalated_at, "color": "bg-red-500", "icon": "alert-triangle"}) + + if observation.dept_response_escalated_at: + stages.append({"label": _("Dept Response Escalated"), "timestamp": observation.dept_response_escalated_at, "color": "bg-rose-600", "icon": "shield-alert"}) if observation.department_responded_at: - stages.append({ - "label": _("Department Responded"), - "timestamp": observation.department_responded_at, - "duration_from_prev": _format_duration(observation.forwarded_to_dept_at or observation.activated_at, observation.department_responded_at), - "color": "bg-amber-500", - }) + performed_by = _user_name(observation.department_responded_by) if hasattr(observation, 'department_responded_by') else None + stages.append({"label": _("Department Responded"), "timestamp": observation.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if observation.resolved_at: - stages.append({ - "label": _("Resolved"), - "timestamp": observation.resolved_at, - "duration_from_prev": _format_duration(observation.department_responded_at or observation.forwarded_to_dept_at, observation.resolved_at), - "color": "bg-green-500", - }) + performed_by = _user_name(observation.resolved_by) if hasattr(observation, 'resolved_by') else None + if not performed_by: + resolved_log = _find_log(to_status="resolved") + performed_by = _user_name(resolved_log.changed_by) if resolved_log else None + stages.append({"label": _("Resolved"), "timestamp": observation.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) if observation.closed_at: - stages.append({ - "label": _("Closed"), - "timestamp": observation.closed_at, - "duration_from_prev": _format_duration(observation.resolved_at or observation.department_responded_at, observation.closed_at), - "color": "bg-emerald-600", - }) + performed_by = _user_name(observation.closed_by) if hasattr(observation, 'closed_by') else None + if not performed_by: + closed_log = _find_log(to_status="closed") + performed_by = _user_name(closed_log.changed_by) if closed_log else None + stages.append({"label": _("Closed"), "timestamp": observation.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) - return stages + stages.sort(key=lambda s: s["timestamp"]) + + for i, stage in enumerate(stages): + stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None + + total_time = None + if len(stages) >= 2: + total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) + + return {"stages": stages, "total_time": total_time} # ============================================================================= @@ -161,31 +174,6 @@ def observation_create_public(request): user_agent = request.META.get("HTTP_USER_AGENT", "") attachments = request.FILES.getlist("attachments") - from apps.organizations.models import Location, MainSection, SubSection - - location = None - main_section = None - subsection = None - loc_id = request.POST.get("location") - sec_id = request.POST.get("main_section") - sub_id = request.POST.get("subsection") - - if loc_id: - try: - location = Location.objects.get(id=loc_id) - except Location.DoesNotExist: - pass - if sec_id: - try: - main_section = MainSection.objects.get(id=sec_id) - except MainSection.DoesNotExist: - pass - if sub_id: - try: - subsection = SubSection.objects.get(internal_id=sub_id) - except SubSection.DoesNotExist: - pass - hospital_id = request.POST.get("hospital") hospital = None if hospital_id: @@ -195,19 +183,13 @@ def observation_create_public(request): except Exception: pass - assigned_department = None - if location and hasattr(location, 'department_id') and location.department_id: - assigned_department = location.department - observation = ObservationService.create_observation( description=form.cleaned_data["description"], severity=form.cleaned_data.get("severity", "medium"), - category=form.cleaned_data.get("category"), + category=None, title=form.cleaned_data.get("title", ""), location_text=form.cleaned_data.get("location_text", ""), - location=location, - main_section=main_section, - subsection=subsection, + location_type=form.cleaned_data.get("location_type", ""), incident_datetime=form.cleaned_data.get("incident_datetime"), reporter_staff_id=form.cleaned_data.get("reporter_staff_id", ""), reporter_name=form.cleaned_data.get("reporter_name", ""), @@ -217,7 +199,8 @@ def observation_create_public(request): user_agent=user_agent, attachments=attachments, hospital=hospital, - assigned_department=assigned_department, + area=form.cleaned_data.get("area"), + section=form.cleaned_data.get("section"), source_legacy="public_form", ) @@ -233,8 +216,7 @@ def observation_create_public(request): context = { "form": form, - "categories": ObservationCategory.objects.filter(is_active=True).order_by("sort_order"), - "hospitals": Hospital.objects.filter(is_active=True).order_by("name_en"), + "hospitals": Hospital.objects.filter(status="active").order_by("name"), } return render(request, "observations/public_new.html", context) @@ -289,35 +271,21 @@ def observation_track(request): except Observation.DoesNotExist: error_message = _("No observation found with this tracking code. Please check and try again.") - public_timeline = [] + has_response = False + response_en = "" + response_ar = "" if observation: - for log in observation.status_logs.all().order_by("-created_at"): - public_timeline.append( - { - "type": "status_change", - "title": _("Status Updated"), - "comment": log.comment, - "created_at": log.created_at, - "from_status": log.from_status, - "to_status": log.to_status, - } - ) - for note in observation.notes.filter(is_internal=False).order_by("-created_at"): - public_timeline.append( - { - "type": "note", - "title": _("Update Received"), - "comment": note.note, - "created_at": note.created_at, - } - ) - public_timeline.sort(key=lambda x: x["created_at"], reverse=True) + has_response = bool(observation.department_response_en or observation.department_response_ar) + response_en = observation.department_response_en or "" + response_ar = observation.department_response_ar or "" context = { "observation": observation, - "public_timeline": public_timeline, "error_message": error_message, "tracking_code": tracking_code, + "has_response": has_response, + "response_en": response_en, + "response_ar": response_ar, } return render(request, "observations/public_track.html", context) @@ -336,24 +304,20 @@ def observation_create(request): """ communication_request = None if request.method == "POST": - form = ObservationInternalForm(request.POST, request.FILES, request=request) + form = ObservationInternalForm(request.POST, request=request) if form.is_valid(): try: client_ip = get_client_ip(request) user_agent = request.META.get("HTTP_USER_AGENT", "") user = request.user - attachments = request.FILES.getlist("attachments") observation = ObservationService.create_observation( description=form.cleaned_data["description"], severity=form.cleaned_data.get("severity") or request.POST.get("severity", "medium"), - category=form.cleaned_data.get("category"), - title=form.cleaned_data.get("title", ""), - location_text=form.cleaned_data.get("location_text", ""), - location=form.cleaned_data.get("location"), - main_section=form.cleaned_data.get("main_section"), - subsection=form.cleaned_data.get("subsection"), + category=None, + title="", + location_text="", incident_datetime=form.cleaned_data.get("incident_datetime"), reporter_staff_id=user.employee_id or "", reporter_name=user.get_full_name(), @@ -361,8 +325,7 @@ def observation_create(request): reporter_email=user.email, client_ip=client_ip, user_agent=user_agent, - attachments=attachments, - hospital=user.hospital if hasattr(user, 'hospital') else None, + hospital=form.cleaned_data.get("hospital") or (user.hospital if hasattr(user, 'hospital') else None), assigned_department=form.cleaned_data.get("assigned_department"), source=form.cleaned_data.get("px_source"), source_legacy="staff_portal", @@ -370,17 +333,20 @@ def observation_create(request): assigned_dept = form.cleaned_data.get("assigned_department") assigned_user = form.cleaned_data.get("assigned_to") + section = form.cleaned_data.get("section") if assigned_dept: observation.assigned_department = assigned_dept if assigned_user: observation.assigned_to = assigned_user - observation.status = ObservationStatus.ASSIGNED + observation.status = ObservationStatus.IN_PROGRESS ObservationStatusLog.objects.create( observation=observation, - from_status=ObservationStatus.NEW, - to_status=ObservationStatus.ASSIGNED, + from_status=ObservationStatus.OPEN, + to_status=ObservationStatus.IN_PROGRESS, comment="Auto-assigned during creation", ) + if section: + observation.section = section observation.save() comm_req_id = request.POST.get("comm_req") @@ -513,12 +479,13 @@ def observation_list(request): if date_to: queryset = queryset.filter(created_at__date__lte=date_to) - # Ordering + ALLOWED_ORDER_BY = {"-created_at", "created_at", "-updated_at", "updated_at", "severity", "-severity"} 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) @@ -533,8 +500,10 @@ def observation_list(request): categories = ObservationCategory.objects.filter(is_active=True) - # Statistics - stats = ObservationService.get_statistics() + stats = ObservationService.get_statistics( + hospital=selected_hospital or (user.hospital if not user.is_px_admin() else None), + department=user.department if user.is_department_manager() else None, + ) context = { "page_obj": page_obj, @@ -565,9 +534,9 @@ def observation_detail(request, pk): observation = get_object_or_404( Observation.objects.select_related( "category", "assigned_department", "assigned_to", "triaged_by", "resolved_by", "closed_by", - "location", "main_section", "subsection", "hospital", + "legacy_location", "legacy_main_section", "legacy_subsection", "hospital", "taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification", - ).prefetch_related("attachments", "notes__created_by", "status_logs__changed_by"), + ).prefetch_related("attachments", "observation_notes__created_by", "status_logs__changed_by"), pk=pk, ) @@ -585,7 +554,7 @@ def observation_detail(request, pk): # Get timeline (combine status logs and notes) status_logs = list(observation.status_logs.all()) - notes = list(observation.notes.all()) + notes = list(observation.observation_notes.all()) timeline = [] for log in status_logs: @@ -662,13 +631,67 @@ def observation_detail(request, pk): "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin(), "can_convert": user.is_px_admin() or user.is_hospital_admin(), "can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management(), - "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or (getattr(user, 'is_department_respondent', lambda: False)() and observation.assigned_department == user.department), + "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or (user.is_champion() and observation.assigned_department == user.department), "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), "can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), "can_delete": user.is_px_admin() or user.is_hospital_admin(), "linked_rcas": linked_rcas, } + from django.contrib.contenttypes.models import ContentType + obs_ct = ContentType.objects.get_for_model(observation) + context["content_type_id"] = obs_ct.pk + context["object_id"] = observation.pk + generic_notes = observation.notes.select_related("created_by").all() + context["generic_notes"] = generic_notes + context["generic_notes_count"] = generic_notes.count() + + escalation_targets = [] + escalation_email_subject = "" + escalation_email_body = "" + + if observation.assigned_department: + from apps.organizations.models import Staff as OrgStaff + + dept = observation.assigned_department + dept_holders = [ + h for h in dept.get_role_holders() + if h["staff"].user and h["staff"].user.is_active + ] + escalation_targets.extend(dept_holders) + + hospital_admins = OrgStaff.objects.filter( + user__is_active=True, + user__groups__name__in=["Hospital Admin", "Department Manager"], + ) + if observation.hospital: + hospital_admins = hospital_admins.filter(department__hospital=observation.hospital) + for admin in hospital_admins.select_related("user"): + escalation_targets.append({ + "staff": admin, + "staff_id": str(admin.id), + "name": admin.get_full_name(), + "email": admin.email or (admin.user.email if admin.user else None), + "role_field": "hospital_admin", + "role_label": "Hospital Admin" if admin.user.groups.filter(name="Hospital Admin").exists() else "Department Manager", + }) + + dept_name = dept.get_localized_name() + escalation_email_subject = f"Observation Escalation - {observation.tracking_code} - {dept_name}" + escalation_email_body = ( + f"Dear Team,\n\n" + f"This is to escalate observation {observation.tracking_code} regarding the {dept_name} department.\n\n" + f"Severity: {observation.get_severity_display()}\n" + f"Status: {observation.get_status_display()}\n" + f"Description: {observation.description[:500]}\n\n" + f"Please review and take appropriate action.\n\n" + f"Thank you." + ) + + context["escalation_targets"] = escalation_targets + context["escalation_email_subject"] = escalation_email_subject + context["escalation_email_body"] = escalation_email_body + return render(request, "observations/observation_detail.html", context) @@ -766,33 +789,27 @@ def observation_assign(request, pk): observation.assigned_at = timezone.now() reopened = False + new_status = old_status if old_status in ("resolved", "closed"): - observation.status = ObservationStatus.IN_PROGRESS + new_status = ObservationStatus.IN_PROGRESS observation.resolved_at = None observation.resolved_by = None observation.closed_at = None observation.closed_by = None reopened = True + elif old_status == ObservationStatus.OPEN: + new_status = ObservationStatus.IN_PROGRESS observation.save() - ObservationStatusLog.objects.create( + ObservationService.change_status( observation=observation, - from_status=old_status, - to_status=observation.status, + new_status=new_status, changed_by=request.user, comment=f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}" + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), ) - ObservationNote.objects.create( - observation=observation, - note=f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}" - + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), - created_by=request.user, - is_internal=True, - ) - messages.success(request, f"Observation {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.") except User.DoesNotExist: messages.error(request, "User not found.") @@ -800,6 +817,57 @@ def observation_assign(request, pk): return redirect("observations:observation_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def observation_activate(request, pk): + """Activate observation and assign to current user.""" + observation = get_object_or_404(Observation, pk=pk) + user = request.user + + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to activate observations.")) + return redirect("observations:observation_detail", pk=pk) + + if observation.assigned_to == user: + messages.info(request, _("This observation is already assigned to you.")) + return redirect("observations:observation_detail", pk=pk) + + old_assignee = observation.assigned_to + old_status = observation.status + + observation.assigned_to = user + observation.assigned_at = timezone.now() + observation.save(update_fields=["assigned_to", "assigned_at"]) + + if old_status == ObservationStatus.OPEN: + ObservationService.change_status( + observation=observation, + new_status=ObservationStatus.IN_PROGRESS, + changed_by=user, + comment=f"Observation activated and assigned to {user.get_full_name()}" + + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), + ) + else: + ObservationNote.objects.create( + observation=observation, + note=f"Observation reassigned to {user.get_full_name()}" + + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), + created_by=user, + is_internal=True, + ) + + StaffActivityService.log_from_request( + request, + activity_type="update", + description=f"Activated observation {observation.tracking_code or observation.id}", + content_object=observation, + module="observations", + ) + + messages.success(request, _("Observation activated and assigned to you successfully.")) + return redirect("observations:observation_detail", pk=pk) + + @login_required @require_http_methods(["POST"]) def observation_reopen(request, pk): @@ -817,27 +885,18 @@ def observation_reopen(request, pk): old_status = observation.status note_text = request.POST.get("note", "Observation reopened") - observation.status = ObservationStatus.IN_PROGRESS - observation.resolved_at = None - observation.resolved_by = None - observation.closed_at = None - observation.closed_by = None - observation.save() - - ObservationStatusLog.objects.create( + ObservationService.change_status( observation=observation, - from_status=old_status, - to_status=ObservationStatus.IN_PROGRESS, + new_status=ObservationStatus.IN_PROGRESS, changed_by=user, comment=note_text, ) - ObservationNote.objects.create( - observation=observation, - note=note_text, - created_by=user, - is_internal=True, - ) + observation.resolved_at = None + observation.resolved_by = None + observation.closed_at = None + observation.closed_by = None + observation.save(update_fields=["resolved_at", "resolved_by", "closed_at", "closed_by"]) messages.success(request, "Observation reopened successfully.") return redirect("observations:observation_detail", pk=pk) @@ -959,11 +1018,27 @@ def observation_send_to_department(request, pk): from apps.organizations.models import Department try: - department = Department.objects.get(pk=department_id, status="active") + department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: messages.error(request, _("Department not found.")) return redirect("observations:observation_detail", pk=pk) + if not department.champion and not department.manager: + messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")) + return redirect("observations:observation_detail", pk=pk) + + contact_person_id = request.POST.get("contact_person_id") + if not contact_person_id: + messages.error(request, _("Please select a contact person.")) + return redirect("observations:observation_detail", pk=pk) + + contact_info = department.is_valid_contact_person(contact_person_id) + if not contact_info: + messages.error(request, _("Selected person is not a role holder in this department.")) + return redirect("observations:observation_detail", pk=pk) + + contact_person = contact_info["staff"] + note_en = request.POST.get("note_en", "").strip() note_ar = request.POST.get("note_ar", "").strip() recipient_type = request.POST.get("recipient_type", "staff") @@ -978,6 +1053,8 @@ def observation_send_to_department(request, pk): # Use assigned_department as the outgoing department (reusing existing field) observation.assigned_department = department observation.forwarded_to_dept_at = timezone.now() + observation.sent_to_department = True + observation.sent_to_department_at = timezone.now() sla_config = observation.get_sla_config() if sla_config and sla_config.dept_response_hours: @@ -988,19 +1065,12 @@ def observation_send_to_department(request, pk): observation.dept_response_second_reminder_sent_at = None observation.dept_response_escalated_at = None - if observation.status in ("new", "triaged"): - observation.status = "contacted" + old_status = observation.status + observation.contact_status = "contacted" + observation.contact_status_at = timezone.now() + observation.contact_status_by = user observation.save() - # Create status log - ObservationStatusLog.objects.create( - observation=observation, - from_status="", - to_status="contacted", - changed_by=user, - comment=combined_note or f"Observation sent to {department.get_localized_name()} for response", - ) - # Create note ObservationNote.objects.create( observation=observation, @@ -1010,18 +1080,50 @@ def observation_send_to_department(request, pk): ) try: + import secrets from apps.notifications.settings_service import NotificationServiceWithSettings + + # Generate token for response link + response_token = secrets.token_urlsafe(32) + observation.response_token = response_token + observation.response_token_sent_at = timezone.now() + observation.save(update_fields=["response_token", "response_token_sent_at"]) + + # Build response link + from django.contrib.sites.shortcuts import get_current_site + current_site = get_current_site(request) + domain = current_site.domain if current_site else request.get_host() + response_link = f"https://{domain}/observations/{observation.pk}/respond/{response_token}/" + NotificationServiceWithSettings.send_observation_department_assigned( department, observation, context_note_en=note_en, context_note_ar=note_ar, recipient_type=recipient_type, ) - if department.respondent and department.respondent.user and department.respondent.user.email: - from apps.notifications.services import NotificationService + contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) + if contact_email: + from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( - email=department.respondent.user.email, + email=contact_email, subject=f"Observation {observation.tracking_code} - Response Required", - message=f"An observation has been sent to your department ({department.get_localized_name()}) for response. Please submit your response before the deadline: {observation.dept_response_sla_due_at}", + message=f"An observation has been sent to your department ({department.get_localized_name()}) for response.\n\nSubmit your response here: {response_link}\n\nDeadline: {observation.dept_response_sla_due_at}", + html_message=f""" +
+ {get_email_header_html()} +
+

Observation {observation.tracking_code} - Response Required

+

An observation has been sent to your department ({department.get_localized_name()}) for response.

+ + + +
Tracking Code:{observation.tracking_code}
Deadline:{observation.dept_response_sla_due_at}
+ +

This link can only be used once. After submission, it will expire.

+
+
+""", related_object=observation, ) except Exception as e: @@ -1036,12 +1138,110 @@ def observation_send_to_department(request, pk): else: messages.success( request, - _("Observation sent to %(dept)s. Department respondents have been notified.") + _("Observation sent to %(dept)s. Department champions have been notified.") % {"dept": department.get_localized_name()}, ) return redirect("observations:observation_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def observation_escalate(request, pk): + from apps.organizations.models import Staff + from django.contrib import messages as django_messages + + observation = get_object_or_404(Observation, pk=pk) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + ): + messages.error(request, _("You don't have permission to escalate observations.")) + return redirect("observations:observation_detail", pk=pk) + + if observation.status in ("closed", "cancelled", "rejected"): + messages.error(request, _("Cannot escalate a closed, cancelled, or rejected observation.")) + return redirect("observations:observation_detail", pk=pk) + + escalate_to_id = request.POST.get("escalate_to", "") + reason = request.POST.get("reason", "") + + escalate_to_staff = None + escalate_to_user = None + + if escalate_to_id: + try: + escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active") + if escalate_to_staff.user and escalate_to_staff.user.is_active: + escalate_to_user = escalate_to_staff.user + except Staff.DoesNotExist: + pass + + if not escalate_to_user: + messages.error(request, _("Please select a valid person to escalate to.")) + return redirect("observations:observation_detail", pk=pk) + + observation.escalated_at = timezone.now() + observation.save(update_fields=["escalated_at"]) + + ObservationStatusLog.objects.create( + observation=observation, + from_status=observation.status, + to_status=observation.status, + changed_by=user, + comment=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", + ) + + ObservationNote.objects.create( + observation=observation, + note=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", + created_by=user, + is_internal=True, + ) + + email_subject = request.POST.get("email_subject", f"Observation Escalated - {observation.tracking_code}") + email_body = request.POST.get("email_body", "") + + if escalate_to_user.email: + try: + from apps.notifications.services import NotificationService, get_email_header_html + NotificationService.send_email( + email=escalate_to_user.email, + subject=email_subject, + message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Observation Escalated - {observation.tracking_code}

+

An observation has been escalated to you.

+ + + + +
Tracking Code:{observation.tracking_code}
Escalated by:{user.get_full_name()}
Reason:{reason or 'N/A'}
+
+

{email_body}

+
+
+
+""", + related_object=observation, + metadata={ + "notification_type": "observation_escalated", + "escalated_by": str(user.id), + "reason": reason, + }, + ) + except Exception as e: + logger.error(f"Failed to send observation escalation email: {e}") + + messages.success(request, _(f"Observation escalated to {escalate_to_staff.get_full_name()}.")) + return redirect("observations:observation_detail", pk=pk) + + @login_required @require_http_methods(["POST"]) def observation_send_to(request, pk): @@ -1049,7 +1249,7 @@ def observation_send_to(request, pk): Unified AJAX endpoint to send observation to either a person or department. """ from django.http import JsonResponse - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html from apps.accounts.models import User observation = get_object_or_404(Observation, pk=pk) @@ -1099,10 +1299,15 @@ def observation_send_to(request, pk): subject=f"Observation Assigned - {observation.tracking_code}", message=f"You have been assigned to observation #{observation.tracking_code}.", html_message=f""" -

You have been assigned to observation #{observation.tracking_code}.

-

Title: {observation.title or 'N/A'}

- {f'

Note: {note}

' if note else ''} -

View Observation

+
+ {get_email_header_html()} +
+

You have been assigned to observation #{observation.tracking_code}.

+

Title: {observation.title or 'N/A'}

+ {f'

Note: {note}

' if note else ''} + View Observation +
+
""", related_object=observation, ) @@ -1118,16 +1323,38 @@ def observation_send_to(request, pk): }, status=400) try: - department = Department.objects.get(pk=department_id, status="active") + department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) + if not department.champion and not department.manager: + return JsonResponse({ + "success": False, + "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")), + }, status=400) + + contact_person_id = request.POST.get("contact_person_id") + if not contact_person_id: + return JsonResponse({ + "success": False, + "error": str(_("Please select a contact person.")), + }, status=400) + + contact_info = department.is_valid_contact_person(contact_person_id) + if not contact_info: + return JsonResponse({ + "success": False, + "error": str(_("Selected person is not a role holder in this department.")), + }, status=400) + # Send to department observation.assigned_department = department observation.forwarded_to_dept_at = timezone.now() + observation.sent_to_department = True + observation.sent_to_department_at = timezone.now() sla_config = observation.get_sla_config() if sla_config and sla_config.dept_response_hours: @@ -1138,21 +1365,20 @@ def observation_send_to(request, pk): observation.dept_response_second_reminder_sent_at = None observation.dept_response_escalated_at = None - message = f"Observation sent to {department.get_localized_name()}." + message = f"Observation sent to {department.get_localized_name()} — {contact_info['name']} ({contact_info['role_label']})." - # Change status to contacted if new or triaged - if observation.status in ("new", "triaged"): - observation.status = "contacted" + old_status_before_send = observation.status + observation.contact_status = "contacted" + observation.contact_status_at = timezone.now() + observation.contact_status_by = user observation.save() - # Create status log - ObservationStatusLog.objects.create( + ObservationNote.objects.create( observation=observation, - from_status="", - to_status="contacted", - changed_by=user, - comment=note or f"Observation sent to {recipient_type}", + note=note or f"Sent to {recipient_type}", + created_by=user, + is_internal=True, ) # Send department notification if applicable @@ -1240,10 +1466,9 @@ Generate a JSON response with: except Exception as e: logger.warning(f"AI summary of department response failed: {e}") - # Create status log ObservationStatusLog.objects.create( observation=observation, - from_status="", + from_status=observation.status, to_status=observation.status, changed_by=user, comment=f"Department response submitted by {user.get_full_name()}", @@ -1257,7 +1482,63 @@ Generate a JSON response with: is_internal=True, ) + try: + 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("observation", observation.tracking_code) + + if observation.reporter_phone: + NotificationService.send_sms( + phone=observation.reporter_phone, + message=f"PX360: Your observation {observation.tracking_code} has been responded to. View details: {track_url}", + related_object=observation, + metadata={"notification_type": "observation_department_response"}, + ) + + if observation.reporter_email: + email_subject = f"PX360: Response to Your Observation {observation.tracking_code}" + email_body = ( + f"Dear Valued Reporter,\n\n" + f"Your observation {observation.tracking_code} has been responded to.\n\n" + f"To view the full response, please visit:\n{track_url}\n\n" + f"Thank you for your contribution.\n\n" + f"Tracking Code: {observation.tracking_code}\n" + f"This is an automated message from PX 360." + ) + NotificationService.send_email( + email=observation.reporter_email, + subject=email_subject, + message=email_body, + html_message=f""" +
+ {get_email_header_html()} +
+

Response to Your Observation

+

Dear Valued Reporter,

+

Your observation {observation.tracking_code} has been responded to.

+ +

Tracking Code: {observation.tracking_code}

+

Thank you for your contribution.

+
+
+""", + related_object=observation, + metadata={"notification_type": "observation_department_response_email"}, + ) + except Exception as e: + logger.warning(f"Failed to send observation response notification: {e}") + messages.success(request, "Department response submitted successfully.") + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + from django.http import JsonResponse + from django.urls import reverse + redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("observations:observation_detail", kwargs={"pk": pk}) + return JsonResponse({"success": True, "redirect_url": redirect_url}) + if user.department: + return redirect("organizations:department_detail", pk=user.department.pk) return redirect("observations:observation_detail", pk=pk) context = { @@ -1287,35 +1568,103 @@ def observation_review_dept_response(request, pk): notes = request.POST.get("acceptance_notes", "").strip() - observation.dept_response_acceptance_status = status - observation.dept_response_accepted_by = user - observation.dept_response_accepted_at = timezone.now() - observation.dept_response_acceptance_notes = notes - observation.save( - update_fields=[ - "dept_response_acceptance_status", - "dept_response_accepted_by", - "dept_response_accepted_at", - "dept_response_acceptance_notes", - ] - ) + if status == "not_acceptable": + observation.dept_response_acceptance_status = "not_acceptable" + observation.dept_response_accepted_by = user + observation.dept_response_accepted_at = timezone.now() + observation.dept_response_acceptance_notes = notes + observation.department_response_en = "" + observation.department_response_ar = "" + observation.department_response_summary_en = "" + observation.department_response_summary_ar = "" + observation.department_responded_at = None + observation.department_responded_by = None + observation.save( + update_fields=[ + "dept_response_acceptance_status", + "dept_response_accepted_by", + "dept_response_accepted_at", + "dept_response_acceptance_notes", + "department_response_en", + "department_response_ar", + "department_response_summary_en", + "department_response_summary_ar", + "department_responded_at", + "department_responded_by", + ] + ) - ObservationStatusLog.objects.create( - observation=observation, - from_status="", - to_status=observation.status, - changed_by=user, - comment=f"Department response marked as {status} by {user.get_full_name()}", - ) + dept = observation.assigned_department + if dept and dept.champion and dept.champion.user and dept.champion.user.email: + try: + from apps.notifications.services import NotificationService, get_email_header_html - ObservationNote.objects.create( - observation=observation, - note=f"Department response review: {status}. {notes}", - created_by=user, - is_internal=True, - ) + NotificationService.send_email( + email=dept.champion.user.email, + subject=f"Action Required: Observation {observation.tracking_code} - Response Rejected", + message=( + f"Your department's response for observation {observation.tracking_code} has been rejected.\n\n" + f"Reason: {notes}\n\n" + f"Please revise and resubmit your response." + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Response Rejected - Observation {observation.tracking_code}

+

Your department's response for observation {observation.tracking_code} has been rejected.

+
+

Reason: {notes}

+
+

Please revise and resubmit your response.

+
+
+""", + related_object=observation, + ) + except Exception: + import logging + logging.getLogger(__name__).exception("Failed to send observation dept response rejection email") + + ObservationNote.objects.create( + observation=observation, + note=f"Department response rejected by {user.get_full_name()}. Reason: {notes}", + created_by=user, + is_internal=True, + ) + + messages.success(request, "Department response rejected. The department has been notified to resubmit.") + else: + observation.dept_response_acceptance_status = status + observation.dept_response_accepted_by = user + observation.dept_response_accepted_at = timezone.now() + observation.dept_response_acceptance_notes = notes + observation.save( + update_fields=[ + "dept_response_acceptance_status", + "dept_response_accepted_by", + "dept_response_accepted_at", + "dept_response_acceptance_notes", + ] + ) + + ObservationStatusLog.objects.create( + observation=observation, + from_status=observation.status, + to_status=observation.status, + changed_by=user, + comment=f"Department response marked as {status} by {user.get_full_name()}", + ) + + ObservationNote.objects.create( + observation=observation, + note=f"Department response review: {status}. {notes}", + created_by=user, + is_internal=True, + ) + + messages.success(request, f"Department response marked as {status}.") - messages.success(request, f"Department response marked as {status}.") return redirect("observations:observation_detail", pk=pk) @@ -1341,17 +1690,27 @@ def observation_send_dept_response_reminder(request, pk): reminder_type = request.POST.get("reminder_type", "first") try: - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html 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) for recipient in recipients: NotificationService.send_email( email=recipient.email, subject=f"Reminder: Observation {observation.tracking_code} - Response Required", message=f"This is a reminder that observation {observation.tracking_code} is awaiting your department's response. Please submit your response as soon as possible.", + html_message=f""" +
+ {get_email_header_html()} +
+

Reminder: Response Required

+

This is a reminder that observation {observation.tracking_code} is awaiting your department's response.

+

Please submit your response as soon as possible.

+
+
+""", related_object=observation, ) @@ -1535,3 +1894,83 @@ def observation_restore(request, pk): observation.restore() messages.success(request, _("Observation restored successfully.")) return redirect("observations:observation_list") + + +def observation_respond_with_token(request, pk, token): + """ + Public-facing form for department staff to submit response to an observation. + Does NOT require authentication. Validates token and checks validity. + """ + from apps.core.ai_service import AIService + from apps.notifications.services import NotificationService + from apps.core.utils import build_public_track_url + + observation = get_object_or_404(Observation, pk=pk) + + if observation.response_token != token or not observation.response_token: + return render(request, "observations/response_token_invalid.html", {"observation": observation}) + + if observation.response_token_used: + return render(request, "observations/response_already_submitted.html", {"observation": observation}) + + if request.method == "POST": + response_en = request.POST.get("response_en", "").strip() + response_ar = request.POST.get("response_ar", "").strip() + response = response_en or response_ar + + if not response: + return render(request, "observations/response_form_token.html", { + "observation": observation, + "error": "Please enter a response in at least one language.", + }) + + observation.department_response_en = response_en + observation.department_response_ar = response_ar + observation.department_responded_at = timezone.now() + observation.dept_response_is_overdue = False + observation.dept_response_acceptance_status = "pending" + observation.response_token_used = True + observation.save() + + # AI summary + try: + import json + prompt = f"""Summarize the following department response to a staff observation in 2-3 concise sentences. + +Observation: {observation.description[:500]} +Department response: {response[:500]} + +Generate JSON with "summary_en" and "summary_ar".""" + result = AIService.chat_completion(prompt=prompt, response_format="json_object") + parsed = json.loads(result) + observation.department_response_summary_en = parsed.get("summary_en", "") + observation.department_response_summary_ar = parsed.get("summary_ar", "") + observation.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) + except Exception: + pass + + # Notify reporter + track_url = build_public_track_url("observation", observation.tracking_code) + if observation.reporter_phone: + try: + NotificationService.send_sms( + phone=observation.reporter_phone, + message=f"PX360: Your observation {observation.tracking_code} has been responded to. View: {track_url}", + related_object=observation, + ) + except Exception: + pass + if observation.reporter_email: + try: + NotificationService.send_email( + email=observation.reporter_email, + subject=f"PX360: Response to Your Observation {observation.tracking_code}", + message=f"Your observation has been responded to.\n\nView: {track_url}", + related_object=observation, + ) + except Exception: + pass + + return render(request, "observations/response_success_token.html", {"observation": observation}) + + return render(request, "observations/response_form_token.html", {"observation": observation}) diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py index baeb255..f7e9b2c 100644 --- a/apps/organizations/admin.py +++ b/apps/organizations/admin.py @@ -4,7 +4,20 @@ Organizations admin from django.contrib import admin -from .models import Department, Hospital, Organization, Patient, Staff, Location, MainSection, SubSection +from .models import ( + Department, + Hospital, + LegacyHierarchyMapping, + LegacyLocation, + LegacyMainSection, + LegacySubSection, + Section, + OrgSubSection, + SubSection, + Organization, + Patient, + Staff, +) @admin.register(Organization) @@ -148,9 +161,9 @@ class StaffAdmin(admin.ModelAdmin): for staff in queryset: if not staff.user and staff.email: try: - user, was_created, password = StaffService.create_user_for_staff(staff, role='staff', request=request) - if was_created and password: - StaffService.send_credentials_email(staff, password, request) + user, was_created, _password = StaffService.create_user_for_staff(staff, role='staff', request=request) + if was_created and user: + StaffService.send_password_reset_email(staff, request) created += 1 except Exception as e: failed += 1 @@ -161,8 +174,8 @@ class StaffAdmin(admin.ModelAdmin): create_user_accounts.short_description = "Create user accounts for selected staff" - def send_credentials_emails(self, request, queryset): - """Admin action to send credential emails to selected staff""" + def send_password_reset_emails(self, request, queryset): + """Admin action to send password reset links to selected staff""" from .services import StaffService sent = 0 @@ -170,19 +183,16 @@ class StaffAdmin(admin.ModelAdmin): for staff in queryset: if staff.user and staff.email: try: - password = StaffService.generate_password() - staff.user.set_password(password) - staff.user.save() - StaffService.send_credentials_email(staff, password, request) + StaffService.send_password_reset_email(staff, request) sent += 1 except Exception as e: failed += 1 self.message_user( - request, f"Sent {sent} credential emails. Failed: {failed}", level="success" if failed == 0 else "warning" + request, f"Sent {sent} password reset emails. Failed: {failed}", level="success" if failed == 0 else "warning" ) - send_credentials_emails.short_description = "Send credential emails to selected staff" + send_password_reset_emails.short_description = "Send password reset links to selected staff" @admin.register(Patient) @@ -226,6 +236,9 @@ class PatientAdmin(admin.ModelAdmin): return qs.select_related("primary_hospital") -admin.site.register(Location) -admin.site.register(MainSection) +admin.site.register(LegacyLocation) +admin.site.register(LegacyMainSection) +admin.site.register(LegacySubSection) +admin.site.register(OrgSubSection) admin.site.register(SubSection) +admin.site.register(LegacyHierarchyMapping) diff --git a/apps/organizations/management/commands/backfill_department_hierarchy.py b/apps/organizations/management/commands/backfill_department_hierarchy.py new file mode 100644 index 0000000..8f0bf63 --- /dev/null +++ b/apps/organizations/management/commands/backfill_department_hierarchy.py @@ -0,0 +1,356 @@ +import re + +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.complaints.models import Complaint +from apps.organizations.models import ( + Department, + LegacyHierarchyMapping, + LegacyMainSection, + LegacySubSection, + Section, +) + + +def _normalize(s): + if not s: + return "" + s = s.lower().strip() + for suffix in [ + " clinics", + " clinic", + " department", + " dept", + " ward", + " unit", + " services", + ]: + s = s.replace(suffix, "") + s = re.sub(r"\s+", " ", s).strip() + return s + + +MANUAL_MAP = { + 2: "Internal Medicine", + 5: "Internal Medicine", + 6: "Internal Medicine", + 9: "Internal Medicine", + 11: "Internal Medicine", + 13: "Internal Medicine", + 14: "Internal Medicine", + 15: "Surgeries", + 18: "Surgeries", + 20: "Surgeries", + 21: "Surgeries", + 22: "Surgeries", + 23: "Surgeries", + 32: "Surgeries", + 34: "Emergency Department", + 35: "Critical Care", + 36: "Pediatric Department", + 37: "Pediatric Department", + 38: "Critical Care", + 39: "Pediatric Department", + 40: "Inpatient Department", + 41: "Inpatient Department", + 42: "Inpatient Department", + 43: "Anesthesia & OR Department", + 44: "Anesthesia & OR Department", + 24: "Surgeries", + 97: "Patient Relations & Patient Experience Department", + 45: "Surgeries", + 46: "Inpatient Department", + 47: "Obstetrics & Gynecology", + 48: "Inpatient Department", + 50: "Medical Ancillary Services", + 51: "Medical Ancillary Services", + 53: "Pharmacy", + 55: "Medical Ancillary Services", + 56: "Medical Ancillary Services", + 57: "Medical Ancillary Services", + 58: "Medical Ancillary Services", + 59: "Medical Ancillary Services", + 60: "Medical Ancillary Services", + 61: "Medical Ancillary Services", + 62: "Surgeries", + 78: "Nursing Department", + 79: "Nursing Department", + 88: "Outpatient Department", + 89: "Corporate Administration", + 90: "Finance Department", + 92: "Outpatient Department", + 94: "Outpatient Department", + 95: "Security Department", + 96: "Emergency Department", + 98: "Emergency Department", + 99: "Outpatient Department", + 100: "Emergency Department", + 102: "Medical Records Department", + 103: "Outpatient Department", + 104: "Medical Approvals Department", + 105: "Security Department", + 109: "Nursing Department", + 110: "Nursing Department", + 111: "Security Department", + 113: "Outpatient Department", + 147: "Internal Medicine", + 149: "Housekeeping & Hospitality Department", + 151: "Nursing Department", + 152: "Nursing Department", + 153: "Nursing Department", + 155: "Nursing Department", + 156: "Nursing Department", + 157: "Nursing Department", + 158: "Nursing Department", + 159: "Nursing Department", + 160: "Nursing Department", + 161: "Nursing Department", + 162: "Nursing Department", + 163: "Nursing Department", + 164: "Food Services Department", + 165: "Housekeeping & Hospitality Department", + 167: "Outpatient Department", + 168: "Corporate Administration", + 169: "Finance Department", + 170: "Patient Relations & Patient Experience Department", + 171: "Patient Affairs Department", + 172: "Inpatient Department", + 173: "Medical Approvals Department", + 174: "Executive Administration", + 176: "Obstetrics & Gynecology", + 177: "Nursing Department", + 178: "Nursing Department", + 180: "Laboratory", + 182: "Nursing Department", + 183: "Nursing Department", + 184: "Patient Affairs Department", + 185: "Nursing Department", + 186: "Radiology", + 187: "Medical Ancillary Services", + 188: "Internal Medicine", + 189: "Pediatric Department", + 190: "Pediatric Department", + 191: "Pediatric Department", + 192: "Pediatric Department", + 193: "Surgeries", + 194: "Surgeries", + 195: "Pediatric Department", + 196: "Pediatric Department", + 197: "Surgeries", + 198: "Obstetrics & Gynecology", + 199: "Internal Medicine", + 200: "Internal Medicine", + 201: "Medical Ancillary Services", + 202: "Pediatric Department", + 203: "Corporate Administration", + 205: "Pharmacy", + 206: "Medical Ancillary Services", + 207: "Facility Management & Maintenance", + 208: "Internal Medicine", + 209: "Pediatric Department", + 210: "Emergency Department", + 211: "Laboratory", + 212: "Surgeries", + 213: "Information Technology", + 215: "Outpatient Department", + 216: "Critical Care", + 219: "Laboratory", + 220: "Emergency Department", + 222: "Information Technology", + 223: "Outpatient Department", + 224: "Information Technology", + 225: "Medical Ancillary Services", + 226: "Medical Records Department", + 227: "Medical Ancillary Services", + 228: "Medical Records Department", + 229: "Inpatient Department", + 230: "Finance Department", +} + + +class Command(BaseCommand): + help = "Backfill complaint.department from legacy subsection hierarchy" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be updated without making changes", + ) + parser.add_argument( + "--backfill-section", + action="store_true", + help="Also backfill complaint.section from LegacyHierarchyMapping", + ) + parser.add_argument( + "--model", + type=str, + default="complaint", + choices=["complaint"], + help="Model to backfill (default: complaint)", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + backfill_section = options["backfill_section"] + + self._build_lookup_tables() + + if backfill_section: + self._backfill_sections(dry_run) + return + + complaints = Complaint.objects.filter( + department__isnull=True, + legacy_subsection__isnull=False, + ).select_related("legacy_subsection", "legacy_main_section") + + self.stdout.write(f"Complaints to backfill: {complaints.count()}") + + matched = 0 + unmatched = 0 + unmatched_details = {} + + with transaction.atomic(): + for complaint in complaints: + dept = self._resolve_department(complaint) + if dept: + matched += 1 + if not dry_run: + complaint.department = dept + complaint.save(update_fields=["department"]) + else: + unmatched += 1 + ss = complaint.legacy_subsection + key = (ss.name_en, ss.main_section.name_en if ss.main_section else "?") + unmatched_details[key] = unmatched_details.get(key, 0) + 1 + + if dry_run: + transaction.set_rollback(True) + + self.stdout.write(self.style.SUCCESS(f"\nMatched: {matched}")) + self.stdout.write(self.style.WARNING(f"Unmatched: {unmatched}")) + + if unmatched_details: + self.stdout.write("\nUnmatched subsections:") + for (name, ms), cnt in sorted( + unmatched_details.items(), key=lambda x: -x[1] + ): + self.stdout.write(f" {cnt:4d} | {ms[:15]:15s} | {name[:50]}") + + def _build_lookup_tables(self): + self.dept_by_name = {} + for d in Department.objects.all(): + self.dept_by_name[d.name_en.lower().strip()] = d + + self.dept_by_old_name = {} + self.section_by_old_name = {} + for m in LegacyHierarchyMapping.objects.select_related("main_section", "subsection"): + key = m.old_subsection_en.lower().strip() + if key and m.main_section: + self.dept_by_old_name[key] = m.main_section + if key and m.subsection: + self.section_by_old_name[key] = m.subsection + + self.norm_dept_names = {} + for name, dept in self.dept_by_name.items(): + self.norm_dept_names[_normalize(name)] = dept + + self.stdout.write( + f"Lookup tables: {len(self.dept_by_name)} depts, " + f"{len(self.dept_by_old_name)} legacy mappings, " + f"{len(self.section_by_old_name)} section mappings" + ) + + def _backfill_sections(self, dry_run): + """Backfill complaint.section from LegacyHierarchyMapping.""" + complaints = Complaint.objects.filter( + section__isnull=True, + legacy_subsection__isnull=False, + ).select_related("legacy_subsection") + + self.stdout.write(f"Complaints to backfill section: {complaints.count()}") + + matched = 0 + unmatched = 0 + + with transaction.atomic(): + for complaint in complaints: + section = self._resolve_section(complaint) + if section: + matched += 1 + if not dry_run: + complaint.section = section + complaint.save(update_fields=["section"]) + else: + unmatched += 1 + + if dry_run: + transaction.set_rollback(True) + + self.stdout.write(self.style.SUCCESS(f"\nSection matched: {matched}")) + self.stdout.write(self.style.WARNING(f"Section unmatched: {unmatched}")) + + def _resolve_section(self, complaint): + """Resolve section from legacy subsection via LegacyHierarchyMapping.""" + ss = complaint.legacy_subsection + if not ss: + return None + + # 1. Exact match via LegacyHierarchyMapping.subsection + ss_name = ss.name_en.lower().strip() + if ss_name in self.section_by_old_name: + return self.section_by_old_name[ss_name] + + # 2. Try to find section by name within the complaint's department + if complaint.department: + section = Section.objects.filter( + department=complaint.department, + name_en__iexact=ss.name_en, + ).first() + if section: + return section + + # Normalized match + norm_ss = _normalize(ss.name_en) + for sec in Section.objects.filter(department=complaint.department): + if _normalize(sec.name_en) == norm_ss: + return sec + + return None + + def _resolve_department(self, complaint): + ss = complaint.legacy_subsection + if not ss: + return None + + # 1. Manual override map (highest priority) + pk = ss.pk + if pk in MANUAL_MAP: + dept_name = MANUAL_MAP[pk] + return self.dept_by_name.get(dept_name.lower().strip()) + + # 2. Exact match via LegacyHierarchyMapping + ss_name = ss.name_en.lower().strip() + if ss_name in self.dept_by_old_name: + return self.dept_by_old_name[ss_name] + + # 3. Exact name match to new Department + if ss_name in self.dept_by_name: + return self.dept_by_name[ss_name] + + # 4. Normalized match + norm_ss = _normalize(ss.name_en) + if norm_ss in self.norm_dept_names: + return self.norm_dept_names[norm_ss] + + # 5. Contains match (one name contains the other) + for norm_dept, dept in self.norm_dept_names.items(): + if len(norm_ss) > 5 and ( + norm_ss in norm_dept or norm_dept in norm_ss + ): + if abs(len(norm_ss) - len(norm_dept)) < 15: + return dept + + return None diff --git a/apps/organizations/management/commands/export_departments_subsections.py b/apps/organizations/management/commands/export_departments_subsections.py index b75016c..a35dab5 100644 --- a/apps/organizations/management/commands/export_departments_subsections.py +++ b/apps/organizations/management/commands/export_departments_subsections.py @@ -3,7 +3,7 @@ import os from django.core.management.base import BaseCommand -from apps.organizations.models import Department, SubSection +from apps.organizations.models import Department, LegacySubSection class Command(BaseCommand): @@ -90,7 +90,7 @@ class Command(BaseCommand): "Main Section (EN)", "Main Section (AR)", ]) - qs = SubSection.objects.select_related( + qs = LegacySubSection.objects.select_related( "location", "main_section" ).order_by("location__name_en", "main_section__name_en", "name_en") for sub in qs: diff --git a/apps/organizations/management/commands/export_org_csv.py b/apps/organizations/management/commands/export_org_csv.py index fa94ba6..6cf319f 100644 --- a/apps/organizations/management/commands/export_org_csv.py +++ b/apps/organizations/management/commands/export_org_csv.py @@ -7,7 +7,7 @@ Usage: import csv import os from django.core.management.base import BaseCommand -from apps.organizations.models import Department, Location, MainSection, SubSection +from apps.organizations.models import Department, LegacyLocation, LegacyMainSection, LegacySubSection class Command(BaseCommand): @@ -46,33 +46,33 @@ class Command(BaseCommand): with open(locations_file, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['ID', 'Name (EN)', 'Name (AR)']) - for loc in Location.objects.all(): + for loc in LegacyLocation.objects.all(): writer.writerow([ str(loc.id), loc.name_en or '', loc.name_ar or '' ]) - self.stdout.write(self.style.SUCCESS(f'Exported {Location.objects.count()} locations to {locations_file}')) + self.stdout.write(self.style.SUCCESS(f'Exported {LegacyLocation.objects.count()} locations to {locations_file}')) # Export main sections main_sections_file = os.path.join(output_dir, 'main_sections.csv') with open(main_sections_file, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['ID', 'Name (EN)', 'Name (AR)']) - for section in MainSection.objects.all(): + for section in LegacyMainSection.objects.all(): writer.writerow([ str(section.id), section.name_en or '', section.name_ar or '' ]) - self.stdout.write(self.style.SUCCESS(f'Exported {MainSection.objects.count()} main sections to {main_sections_file}')) + self.stdout.write(self.style.SUCCESS(f'Exported {LegacyMainSection.objects.count()} main sections to {main_sections_file}')) # Export subsections subsections_file = os.path.join(output_dir, 'subsections.csv') with open(subsections_file, 'w', newline='', encoding='utf-8-sig') as f: writer = csv.writer(f) writer.writerow(['Internal ID', 'Name (EN)', 'Name (AR)', 'Location (EN)', 'Location (AR)', 'Main Section (EN)', 'Main Section (AR)']) - for sub in SubSection.objects.all().select_related('location', 'main_section'): + for sub in LegacySubSection.objects.all().select_related('location', 'main_section'): writer.writerow([ str(sub.internal_id), sub.name_en or '', @@ -82,6 +82,6 @@ class Command(BaseCommand): sub.main_section.name_en if sub.main_section else '', sub.main_section.name_ar if sub.main_section else '' ]) - self.stdout.write(self.style.SUCCESS(f'Exported {SubSection.objects.count()} subsections to {subsections_file}')) + self.stdout.write(self.style.SUCCESS(f'Exported {LegacySubSection.objects.count()} subsections to {subsections_file}')) self.stdout.write(self.style.SUCCESS(f'\nAll exports completed in: {output_dir}')) diff --git a/apps/organizations/management/commands/import_areas.py b/apps/organizations/management/commands/import_areas.py new file mode 100644 index 0000000..5270ec1 --- /dev/null +++ b/apps/organizations/management/commands/import_areas.py @@ -0,0 +1,119 @@ +import re + +import pandas as pd +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.organizations.models import Area, Hospital + + +LOCATION_TYPE_MAP = { + "Inpatient": "IP", + "Outpatient": "OP", + "Emergency": "ER", +} + +AREAS_DATA = [ + ("Labor & Delivery", "IP"), + ("NICU - Neonatal Intensive Care Unit", "IP"), + ("Nursery", "IP"), + ("OB Wards", "IP"), + ("OPD1", "OP"), + ("OPD2", "OP"), + ("OPD3", "OP"), + ("OPD4", "OP"), + ("OPD5", "OP"), + ("OPD6", "OP"), + ("OPD7", "OP"), + ("Main OR", "IP"), + ("Recovery", "IP"), + ("Anesthesia", "GENERAL"), + ("Dialysis", "OP"), + ("Endoscopy", "GENERAL"), + ("ICCU", "IP"), + ("ICU", "IP"), + ("PICU", "IP"), + ("ICU Stepdown", "IP"), + ("PICU Stepdown", "IP"), + ("Stepdown 3-4", "IP"), + ("LTACU", "IP"), + ("Surgical Wards", "IP"), + ("Medical Ward", "IP"), + ("Pediatric Ward", "IP"), + ("Emergency", "ER"), +] + + +def _slugify(name): + slug = re.sub(r"[^a-zA-Z0-9]+", "_", name).strip("_").lower() + return slug + + +class Command(BaseCommand): + help = "Import Areas from the LOCATIONS1 sheet for all 3 hospitals" + + def add_arguments(self, parser): + parser.add_argument( + "--file", + default="data/Final List of Departments - 6th Version.xlsx", + help="Path to the departments Excel file", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be created without writing to DB", + ) + + def handle(self, *args, **options): + filepath = options["file"] + dry_run = options["dry_run"] + + hospitals = Hospital.objects.filter(status="active").order_by("code") + if not hospitals.exists(): + self.stderr.write(self.style.ERROR("No active hospitals found")) + return + + self.stdout.write(f"Found {hospitals.count()} hospitals: {list(hospitals.values_list('code', flat=True))}") + + created_count = 0 + skipped_count = 0 + + with transaction.atomic(): + for hospital in hospitals: + self.stdout.write(f"\nHospital: {hospital.code} ({hospital.name})") + + for name_en, location_type in AREAS_DATA: + code = _slugify(name_en) + + if dry_run: + self.stdout.write(f" [DRY RUN] Would create: {name_en} | code={code} | loc={location_type}") + created_count += 1 + continue + + area, created = Area.objects.get_or_create( + hospital=hospital, + code=code, + defaults={ + "name_en": name_en, + "location_type": location_type, + "status": "active", + }, + ) + + if created: + self.stdout.write(self.style.SUCCESS(f" Created: {name_en} ({location_type})")) + created_count += 1 + else: + if area.location_type != location_type: + area.location_type = location_type + area.save(update_fields=["location_type"]) + self.stdout.write(f" Updated location_type: {name_en} → {location_type}") + else: + skipped_count += 1 + + action = "Would create" if dry_run else "Created" + self.stdout.write(self.style.SUCCESS(f"\n{action}: {created_count} | Skipped (existing): {skipped_count}")) + + if not dry_run: + total = Area.objects.filter(status="active").count() + self.stdout.write(f"Total active Areas in DB: {total}") diff --git a/apps/organizations/management/commands/import_departments.py b/apps/organizations/management/commands/import_departments.py new file mode 100644 index 0000000..03cf1e2 --- /dev/null +++ b/apps/organizations/management/commands/import_departments.py @@ -0,0 +1,333 @@ +import re + +import pandas as pd +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.organizations.models import Department, Hospital, Section, Staff + + +CATEGORY_NORMALIZE = { + "Medical": "medical", + "Medical ": "medical", + "Admintrative": "administrative", + "Adminstrative": "administrative", + "Administrative": "administrative", + "Nursing": "nursing", + "Support Services": "support_services", + "Non-Medical": "non_medical", +} + +LOCATION_TYPE_MAP = { + "OP": "OP", + "IP": "IP", + "ER": "ER", + "GO": "GENERAL", +} + +ROLE_COLUMNS_DEPT = [ + ("manager_3rd", "3rd Manager"), + ("manager_2nd", "2nd Manager"), + ("deputy_manager", "Deputy Manger"), + ("champion", "Champion"), +] + +ROLE_COLUMNS_SECTION = [ + ("supervisor", "Supervisor\nHead Nurse\nHOD In Charge"), + ("deputy_supervisor", "Deputy Supervisor\nDeputy Head Nurse"), +] + +HOSPITAL_LOCATION_MAP = { + "HH-S": "السويدي", + "HH-N": "النزهة", + "HH-A": "العليا", +} + + +def _make_dept_code(hospital_code, dept_name): + prefix = hospital_code.lower().replace("-", "_") + slug = re.sub(r"[^a-zA-Z0-9]", "_", dept_name).strip("_").lower()[:80] + return f"{prefix}_{slug}" + + +def _make_sec_code(hospital_code, dept_name, sec_name): + prefix = hospital_code.lower().replace("-", "_") + dept_slug = re.sub(r"[^a-zA-Z0-9]", "_", dept_name).strip("_").lower()[:40] + sec_slug = re.sub(r"[^a-zA-Z0-9]", "_", sec_name).strip("_").lower()[:40] + return f"{prefix}_{dept_slug}__{sec_slug}" + + +def parse_employee_id(val): + if not val or not isinstance(val, str): + return None + val = val.strip() + if val == "?" or val == "": + return None + m = re.match(r"^(\d+)\s*-", val) + if m: + return m.group(1) + return None + + +def _build_eid_location_map(): + from apps.organizations.models import Staff as S + + return {s.employee_id: s.hospital.code for s in S.objects.select_related("hospital").all()} + + +def _find_staff_at_hospital(eid, hospital_code): + if not eid: + return None + try: + return Staff.objects.select_related("hospital").get(employee_id=eid, hospital__code=hospital_code) + except Staff.DoesNotExist: + return None + + +def _find_staff_at_hospital_fuzzy(name, hospital_code): + if not name: + return None + clean = re.sub(r"\s+", " ", name.strip()).upper() + for staff in Staff.objects.filter(hospital__code=hospital_code): + full = re.sub(r"\s+", " ", staff.name.strip()).upper() if staff.name else "" + if clean in full or full in clean: + return staff + return None + + +class Command(BaseCommand): + help = "Import departments and sections from PX360 Department Breakdown List Excel (multi-hospital)" + + def add_arguments(self, parser): + parser.add_argument("--file", default="Documents/PX360 - Department Breakdown List.xlsx") + parser.add_argument("--roles", action="store_true", help="Backfill role FKs (requires staff already imported)") + parser.add_argument("--hospital-code", required=True, help="Hospital code (HH-S, HH-N, HH-A)") + parser.add_argument("--all-hospitals", action="store_true", help="Import for all hospitals from breakdown list") + + def handle(self, *args, **options): + filepath = options["file"] + do_roles = options["roles"] + hospital_code = options["hospital_code"] + + df = pd.read_excel(filepath, header=0) + df["Main Section"] = df["Main Section"].fillna("").str.strip() + + if options["all_hospitals"]: + for code in ["HH-S", "HH-N"]: + try: + hospital = Hospital.objects.get(code=code) + except Hospital.DoesNotExist: + self.stderr.write(f"Hospital {code} not found, skipping") + continue + if do_roles: + self._backfill_roles(df, code) + else: + self._import_departments_and_sections(df, hospital, code) + else: + try: + hospital = Hospital.objects.get(code=hospital_code) + except Hospital.DoesNotExist: + self.stderr.write(f"Hospital {hospital_code} not found. Create it first.") + return + + if do_roles: + self._backfill_roles(df, hospital_code) + else: + self._import_departments_and_sections(df, hospital, hospital_code) + + @transaction.atomic + def _import_departments_and_sections(self, df, hospital, hospital_code): + df["Main Section"] = df["Main Section"].fillna("").str.strip() + + dept_names = df["Department Name"].dropna().unique() + self.stdout.write(f"[{hospital_code}] Found {len(dept_names)} departments in Excel") + + dept_count = 0 + sec_count = 0 + + for dept_name in sorted(dept_names): + dept_rows = df[df["Department Name"] == dept_name] + first_row = dept_rows.iloc[0] + + cat_raw = first_row["Main Section"] + category = CATEGORY_NORMALIZE.get(cat_raw, "") + + code = _make_dept_code(hospital_code, dept_name) + + dept, created = Department.objects.update_or_create( + hospital=hospital, + code=code, + defaults={ + "name": dept_name, + "name_en": dept_name, + "name_ar": "", + "category": category, + "location_type": "", + "status": "active", + }, + ) + dept_count += 1 + action = "Created" if created else "Updated" + self.stdout.write(f" {action} dept: {dept_name} [{category}]") + + unique_sections = dept_rows.dropna(subset=["Section"]) + seen = set() + for _, sec_row in unique_sections.iterrows(): + sec_name = str(sec_row["Section"]).strip() + if not sec_name or sec_name in seen: + continue + seen.add(sec_name) + + loc_type_raw = str(sec_row.get("LOCATION TYPE", "")).strip() + loc_type = LOCATION_TYPE_MAP.get(loc_type_raw, "") + + area = str(sec_row.get("AREA", "")).strip() if pd.notna(sec_row.get("AREA")) else "" + zone = str(sec_row.get("ZONE", "")).strip() if pd.notna(sec_row.get("ZONE")) else "" + floor = str(sec_row.get("FLOOR", "")).strip() if pd.notna(sec_row.get("FLOOR")) else "" + + sub_location = f"{area}/{zone}".strip("/") if area or zone else "" + + display_en = str(sec_row.get("Name (EN)", "")).strip() if pd.notna(sec_row.get("Name (EN)")) else "" + display_ar = str(sec_row.get("Name (AR)", "")).strip() if pd.notna(sec_row.get("Name (AR)")) else "" + if display_en == "nan": + display_en = "" + if display_ar == "nan": + display_ar = "" + + sec_code = _make_sec_code(hospital_code, dept_name, sec_name) + + Section.objects.update_or_create( + department=dept, + code=sec_code, + defaults={ + "name_en": sec_name, + "name_ar": "", + "location_type": loc_type, + "sub_location": sub_location, + "floor": floor, + "display_name_en": display_en, + "display_name_ar": display_ar, + "status": "active", + }, + ) + sec_count += 1 + + self.stdout.write(self.style.SUCCESS(f"[{hospital_code}] Done: {dept_count} departments, {sec_count} sections")) + + @transaction.atomic + def _backfill_roles(self, df, hospital_code): + self.stdout.write(f"[{hospital_code}] Backfilling roles...") + staff_count = Staff.objects.filter(hospital__code=hospital_code).count() + self.stdout.write(f" Staff at {hospital_code}: {staff_count}") + if staff_count == 0: + self.stderr.write(f" No staff at {hospital_code}, skipping") + return + + target_location = HOSPITAL_LOCATION_MAP.get(hospital_code) + + eid_loc_map = _build_eid_location_map() + + dept_names = df["Department Name"].dropna().unique() + dept_updated = 0 + sec_updated = 0 + unmatched = [] + + for dept_name in sorted(dept_names): + dept_rows = df[df["Department Name"] == dept_name] + + code = _make_dept_code(hospital_code, dept_name) + try: + dept = Department.objects.get(code=code) + except Department.DoesNotExist: + continue + + # Collect all unique role values across all rows for this dept + for field_name, col_name in ROLE_COLUMNS_DEPT: + all_vals = dept_rows[col_name].dropna().unique() + staff = None + for raw_val in all_vals: + raw_val = str(raw_val).strip() + if raw_val == "?" or not raw_val: + continue + eid = parse_employee_id(raw_val) + if eid: + loc = eid_loc_map.get(eid) + if loc == target_location: + staff = _find_staff_at_hospital(eid, hospital_code) + if staff: + break + if not staff: + for raw_val in all_vals: + raw_val = str(raw_val).strip() + if raw_val == "?" or not raw_val: + continue + eid = parse_employee_id(raw_val) + if eid: + staff = _find_staff_at_hospital(eid, hospital_code) + if staff: + break + if not staff and len(all_vals) > 0: + staff = _find_staff_at_hospital_fuzzy(str(all_vals[0]), hospital_code) + + if staff: + setattr(dept, field_name, staff) + else: + for raw_val in all_vals[:1]: + unmatched.append(f"DEPT {hospital_code}/{dept_name} {field_name}: {raw_val}") + + dept.save() + dept_updated += 1 + + # Section-level roles + unique_sections = dept_rows.dropna(subset=["Section"]) + seen = set() + for _, sec_row in unique_sections.iterrows(): + sec_name = str(sec_row["Section"]).strip() + if not sec_name or sec_name in seen: + continue + seen.add(sec_name) + + sec_code = _make_sec_code(hospital_code, dept_name, sec_name) + try: + section = Section.objects.get(code=sec_code) + except Section.DoesNotExist: + continue + + changed = False + for field_name, col_name in ROLE_COLUMNS_SECTION: + raw_val = sec_row.get(col_name) + if pd.isna(raw_val): + continue + raw_val = str(raw_val).strip() + if raw_val == "?" or not raw_val: + continue + + eid = parse_employee_id(raw_val) + staff = None + if eid: + loc = eid_loc_map.get(eid) + if loc == target_location: + staff = _find_staff_at_hospital(eid, hospital_code) + if not staff and eid: + staff = _find_staff_at_hospital(eid, hospital_code) + if not staff: + staff = _find_staff_at_hospital_fuzzy(raw_val, hospital_code) + + if staff: + setattr(section, field_name, staff) + changed = True + else: + unmatched.append(f"SEC {hospital_code}/{dept_name}/{sec_name} {field_name}: {raw_val}") + + if changed: + section.save() + sec_updated += 1 + + self.stdout.write( + self.style.SUCCESS(f"[{hospital_code}] Done: {dept_updated} depts, {sec_updated} sections updated with roles") + ) + + if unmatched: + self.stdout.write(self.style.WARNING(f"\n[{hospital_code}] {len(unmatched)} unmatched roles:")) + for u in unmatched[:50]: + self.stdout.write(f" {u}") diff --git a/apps/organizations/management/commands/import_departments_excel.py b/apps/organizations/management/commands/import_departments_excel.py new file mode 100644 index 0000000..82472c6 --- /dev/null +++ b/apps/organizations/management/commands/import_departments_excel.py @@ -0,0 +1,259 @@ +import re +import uuid + +import pandas as pd +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.organizations.models import ( + Department, + Hospital, + LegacyHierarchyMapping, + Section, + OrgSubSection, + Staff, +) + + +CATEGORY_MAP = { + "medical": "medical", + "adminstrative": "administrative", + "admintrative": "administrative", + "nursing": "nursing", + "support services": "support_services", +} + + +def _clean(val): + if pd.isna(val): + return "" + return str(val).strip() + + +def _clean_multiline(val): + text = _clean(val) + text = text.replace("\n", " ").replace("\r", " ") + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def _extract_employee_id(name_str): + if not name_str: + return None + match = re.match(r"(\d+)\s*-", name_str) + if match: + return match.group(1) + return None + + +def _resolve_staff(name_str): + if not name_str: + return None + emp_id = _extract_employee_id(name_str) + if emp_id: + staff = Staff.objects.filter(employee_id=emp_id).first() + if staff: + return staff + return None + + +def _normalize_category(raw): + if not raw: + return "" + return CATEGORY_MAP.get(raw.lower(), raw.lower()) + + +class Command(BaseCommand): + help = "Import departments, sections, and sub-sections from the Final List of Departments Excel file (4th Version)" + + def add_arguments(self, parser): + parser.add_argument( + "--file", + type=str, + default="data/Final List of Departments - 4th Version.xlsx", + help="Path to the Excel file", + ) + parser.add_argument( + "--hospital-code", + type=str, + default="HH-NZ", + help="Hospital code to assign departments to", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be created without creating it", + ) + + def handle(self, *args, **options): + file_path = options["file"] + hospital_code = options["hospital_code"] + dry_run = options["dry_run"] + + hospital = Hospital.objects.filter(code=hospital_code).first() + if not hospital: + self.stderr.write(self.style.ERROR(f"Hospital with code '{hospital_code}' not found")) + return + + self.stdout.write(f"Importing from: {file_path}") + self.stdout.write(f"Hospital: {hospital.name} ({hospital.code})") + + df = pd.read_excel(file_path, sheet_name="ALL LIST") + + stats = {"departments": 0, "sections": 0, "sub_sections": 0, "mappings": 0, "skipped": 0} + + with transaction.atomic(): + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN - no changes will be saved")) + + dept_cache = {} + + for idx, row in df.iterrows(): + dept_name = _clean(row.get("Department")) + if not dept_name: + stats["skipped"] += 1 + continue + + section_name = _clean(row.get("Section")) + subsection_name = _clean(row.get("Sub-Section")) + area_raw = _clean(row.get("Main Section")) + + dept_key = dept_name + if dept_key not in dept_cache: + code = re.sub(r"[^a-zA-Z0-9]", "_", dept_name.lower())[:100] + category = _normalize_category(area_raw) if area_raw else "" + + defaults = { + "name": dept_name, + "name_en": dept_name, + "category": category, + "main_section": area_raw, + "location_type": self._normalize_location_type(_clean(row.get("Department\nLocation"))), + "sub_location": _clean(row.get("Department\nSub-Location")), + "floor": _clean(row.get("Department\nFloor")), + "manager_3rd": _resolve_staff(_clean(row.get("3rd Manager"))), + "manager_2nd": _resolve_staff(_clean_multiline(row.get("2nd Manager"))), + "deputy_manager": _resolve_staff(_clean(row.get("Deputy Manger"))), + "supervisor": _resolve_staff(_clean(row.get("Supervisor\nHead Nurse\nHOD In Charge"))), + "deputy_supervisor": _resolve_staff( + _clean(row.get("Deputy Supervisor\nDeputy Head Nurse")) + ), + "champion_email": _clean(row.get("Champion Email")), + "old_name_en": _clean(row.get("Unnamed: 22")), + "old_name_ar": _clean(row.get("Unnamed: 23")), + } + + if not dry_run: + existing = Department.objects.filter( + hospital=hospital, name_en=dept_name + ).first() + if existing: + for k, v in defaults.items(): + setattr(existing, k, v) + existing.save() + dept_cache[dept_key] = existing + self.stdout.write(f" ~ Department: {dept_name} (updated)") + else: + dept = Department.objects.create( + hospital=hospital, + code=code, + **defaults, + ) + dept_cache[dept_key] = dept + stats["departments"] += 1 + self.stdout.write(f" + Department: {dept_name} (code={code})") + else: + stats["departments"] += 1 + dept_cache[dept_key] = dept_name + self.stdout.write(f" [DRY] Department: {dept_name} (code={code}, area={area})") + + department = dept_cache.get(dept_key) + if not department: + continue + + if section_name and not subsection_name: + if not dry_run: + sec_code = f"{department.code}__{re.sub(r'[^a-zA-Z0-9]', '_', section_name.lower())[:100]}" + defaults = { + "name_en": section_name, + "location_type": self._normalize_location_type( + _clean(row.get("Department\nLocation")) + ), + "sub_location": _clean(row.get("Department\nSub-Location")), + "floor": _clean(row.get("Department\nFloor")), + "status": "active", + } + sec, created = Section.objects.update_or_create( + department=department, + code=sec_code, + defaults=defaults, + ) + if created: + stats["sections"] += 1 + self.stdout.write(f" + Section: {section_name}") + else: + stats["sections"] += 1 + self.stdout.write(f" [DRY] Section: {section_name}") + + + old_name_en_raw = _clean_multiline(row.get("Unnamed: 22")) + old_name_ar_raw = _clean_multiline(row.get("Unnamed: 23")) + old_loc_en = _clean_multiline(row.get("Unnamed: 24")) + old_loc_ar = _clean_multiline(row.get("Unnamed: 25")) + old_area_en = _clean_multiline(row.get("Unnamed: 26")) + old_area_ar = _clean_multiline(row.get("Unnamed: 27")) + + if old_name_en_raw or old_name_ar_raw: + old_names = [n.strip() for n in re.split(r'[&\n]', old_name_en_raw) if n.strip()] + old_locs = [n.strip() for n in re.split(r'[&\n]', old_loc_en) if n.strip()] + old_areas = [n.strip() for n in re.split(r'[&\n]', old_area_en) if n.strip()] + + if not old_names: + old_names = [""] + + if not dry_run: + for i, old_sub_name in enumerate(old_names): + loc = old_locs[i] if i < len(old_locs) else (old_locs[0] if old_locs else "") + area = old_areas[i] if i < len(old_areas) else (old_areas[0] if old_areas else "") + LegacyHierarchyMapping.objects.update_or_create( + old_location_ar="", + old_main_section_ar=area, + old_subsection_ar=old_sub_name, + defaults={ + "old_location_en": loc, + "old_main_section_en": area, + "old_subsection_en": old_sub_name, + "main_section": department, + }, + ) + stats["mappings"] += 1 + + if dry_run: + transaction.set_rollback(True) + self.stdout.write(self.style.WARNING("DRY RUN - all changes rolled back")) + + self.stdout.write(self.style.SUCCESS( + f"\nImport complete: {stats['departments']} departments, " + f"{stats['sections']} sections, {stats['sub_sections']} sub-sections, " + f"{stats['mappings']} legacy mappings, {stats['skipped']} skipped" + )) + + def _normalize_location_type(self, raw): + if not raw: + return "" + raw_lower = raw.lower().replace("\n", " ").replace("\r", " ") + if "op" in raw_lower and "ip" in raw_lower and "er" in raw_lower: + return "OP,IP,ER" + if "op" in raw_lower and "ip" in raw_lower: + return "OP,IP" + if "op" in raw_lower and "er" in raw_lower: + return "OP,ER" + if "general" in raw_lower or "offices" in raw_lower: + return "GENERAL" + if "ip" in raw_lower or "inpatient" in raw_lower: + return "IP" + if "er" in raw_lower or "emergency" in raw_lower: + return "ER" + if "op" in raw_lower or "outpatient" in raw_lower: + return "OP" + return raw[:20] diff --git a/apps/organizations/management/commands/import_staff_excel.py b/apps/organizations/management/commands/import_staff_excel.py new file mode 100644 index 0000000..6cf9cfe --- /dev/null +++ b/apps/organizations/management/commands/import_staff_excel.py @@ -0,0 +1,280 @@ +import re + +import pandas as pd +from django.core.management.base import BaseCommand +from django.db.models import Q +from django.db import transaction + +from apps.organizations.models import Department, Hospital, Staff + +LOCATION_TO_HOSPITAL = { + "Suwaidi": "HH-S", + "Nuzha": "HH-N", + "Olaya": "HH-A", + "السويدي": "HH-S", + "النزهة": "HH-N", + "العليا": "HH-A", +} + +HR_NAME_ALIASES = { + "Accident And Emergency": "Emergency Medicine Department", + "Corporate Administration": "Executive Administration", + "Senior Management Offices": "Executive Administration", + "Corporate Communication Department": "Patient Experience Department", + "Marketing Department": "Patient Experience Department", + "Porter Department": "Security Department", + "Transportation Department": "Security Department", + "Supply Chain": "Facility Management & Maintenance Department", + "Pharmacy Warehouse Alaziziyah": "Pharmacy Department", + "Family Medicine": "Outpatient Department", + "Business Development": "Executive Administration", + "Continuous Medical Education Managment": "Medical Administration", + "Academic Education And Training Affairs": "Medical Administration", + "Innovation Communication Management": "Medical Administration", + "Transformation And Change Management": "Medical Administration", + "Talent Acquisition Department": "HR Department", + "Internal Audit": "Financial Collection & Claims Department", + "Legal Affairs Department": "Executive Administration", + "Cybersecurity Management": "Information Technology Department", + "Rehabilitation Center": "Outpatient Department", +} + + +def normalize_name(name): + if not name: + return "" + return re.sub(r"\s+", " ", name.strip()).strip() + + +class Command(BaseCommand): + help = "Import staff from employees.xlsx (both Arabic Sheet1 + English Sheet2)" + + def add_arguments(self, parser): + parser.add_argument("--file", default="data/employees.xlsx") + parser.add_argument("--update-existing", action="store_true") + + def handle(self, *args, **options): + filepath = options["file"] + update_existing = options["update_existing"] + + hospitals = {h.code: h for h in Hospital.objects.all()} + if not hospitals: + self.stderr.write("No hospitals found. Create them first.") + return + + self.stdout.write(f"Available hospitals: {list(hospitals.keys())}") + + df_ar = pd.read_excel(filepath, header=None, skiprows=3, sheet_name=0) + df_ar.columns = [ + "_skip", "employee_number", "name_ar", "manager", "national_id", + "location_ar", "department_ar", "section_ar", "job_title_ar", + "mobile", "personal_email", "work_email", + ] + df_ar = df_ar.drop(columns=["_skip"]) + df_ar = df_ar[df_ar["employee_number"].notna()] + df_ar = df_ar[df_ar["name_ar"].notna()] + df_ar["employee_id"] = df_ar["employee_number"].apply( + lambda x: str(int(x)) if pd.notna(x) and str(x).strip().replace(".", "").isdigit() else None + ) + + df_en = pd.read_excel(filepath, header=None, skiprows=3, sheet_name="Sheet2") + df_en.columns = [ + "_skip", "employee_number", "name", "manager", "national_id", + "location", "department", "section", "job_title", "country", + "mobile", "personal_email", "work_email", + ] + df_en = df_en.drop(columns=["_skip"]) + df_en = df_en[df_en["employee_number"].notna()] + df_en = df_en[df_en["name"].notna()] + df_en["employee_id"] = df_en["employee_number"].apply( + lambda x: str(int(x)) if pd.notna(x) and str(x).strip().replace(".", "").isdigit() else None + ) + + en_lookup = {} + for _, row in df_en.iterrows(): + eid = row.get("employee_id") + if eid: + en_lookup[eid] = row + + self.stdout.write(f"Arabic records: {len(df_ar)}, English records: {len(df_en)}") + + dept_cache = {} + stats = { + "created": 0, "updated": 0, "skipped": 0, + "no_dept": 0, "no_hospital": 0, "errors": 0, + } + staff_map = {} + existing_staff = {s.employee_id: s for s in Staff.objects.all()} + + with transaction.atomic(): + for idx, (_, row_ar) in enumerate(df_ar.iterrows(), 1): + try: + emp_id = row_ar.get("employee_id") + if not emp_id: + continue + + name_ar_raw = normalize_name(str(row_ar.get("name_ar", ""))) + if not name_ar_raw: + continue + + parts_ar = name_ar_raw.split(None, 1) + first_name_ar = parts_ar[0] if parts_ar else "" + last_name_ar = parts_ar[1] if len(parts_ar) > 1 else "" + + row_en = en_lookup.get(emp_id) + + if row_en is not None: + name_en = normalize_name(str(row_en.get("name", ""))) + parts_en = name_en.split(None, 1) + first_name = parts_en[0] if parts_en else "" + last_name = parts_en[1] if len(parts_en) > 1 else "" + location_en = normalize_name(str(row_en.get("location", ""))) + dept_en = normalize_name(str(row_en.get("department", ""))) + section_en = normalize_name(str(row_en.get("section", ""))) + job_title_en = normalize_name(str(row_en.get("job_title", ""))) + country = normalize_name(str(row_en.get("country", ""))) + mobile_en = normalize_name(str(row_en.get("mobile", ""))) + work_email_en = normalize_name(str(row_en.get("work_email", ""))) + personal_email_en = normalize_name(str(row_en.get("personal_email", ""))) + else: + name_en = "" + first_name = first_name_ar + last_name = last_name_ar + location_en = "" + dept_en = "" + section_en = "" + job_title_en = "" + country = "" + mobile_en = "" + work_email_en = "" + personal_email_en = "" + + manager_raw = str(row_ar.get("manager", "")).strip() if pd.notna(row_ar.get("manager")) else "" + national_id = str(row_ar.get("national_id", "")).strip() if pd.notna(row_ar.get("national_id")) else "" + job_title_ar_val = normalize_name(str(row_ar.get("job_title_ar", ""))) + dept_ar = normalize_name(str(row_ar.get("department_ar", ""))) + section_ar = normalize_name(str(row_ar.get("section_ar", ""))) + location_ar = normalize_name(str(row_ar.get("location_ar", ""))) + mobile_ar = normalize_name(str(row_ar.get("mobile", ""))) + personal_email_ar = normalize_name(str(row_ar.get("personal_email", ""))) + work_email_ar = normalize_name(str(row_ar.get("work_email", ""))) + + hospital_code = LOCATION_TO_HOSPITAL.get(location_en) or LOCATION_TO_HOSPITAL.get(location_ar) + hospital = hospitals.get(hospital_code) if hospital_code else None + + if not hospital: + stats["no_hospital"] += 1 + continue + + department = None + if dept_en and hospital_code: + cache_key = (hospital_code, dept_en.lower()) + if cache_key not in dept_cache: + dept_cache[cache_key] = self._find_dept( + hospitals[hospital_code], dept_en + ) + department = dept_cache[cache_key] + + if not department and dept_en: + stats["no_dept"] += 1 + + existing = existing_staff.get(emp_id) + + staff_data = { + "name": name_en or name_ar_raw, + "first_name": first_name or first_name_ar, + "last_name": last_name or last_name_ar, + "name_ar": name_ar_raw, + "first_name_ar": first_name_ar, + "last_name_ar": last_name_ar, + "staff_type": "other", + "department_type": "", + "job_title": job_title_en or job_title_ar_val, + "job_title_ar": job_title_ar_val, + "specialization": "", + "email": work_email_en or personal_email_en or work_email_ar or personal_email_ar, + "phone": mobile_en or mobile_ar, + "hospital": hospital, + "department": department, + "civil_id": national_id, + "location": location_en or location_ar, + "location_ar": location_ar, + "department_name": dept_en or dept_ar, + "department_name_ar": dept_ar, + "section": section_en or section_ar, + "section_ar": section_ar, + "subsection": "", + "subsection_ar": "", + "country": country, + "status": "active", + } + + if existing and update_existing: + for k, v in staff_data.items(): + setattr(existing, k, v) + existing.save() + staff_map[emp_id] = existing + stats["updated"] += 1 + elif existing: + staff_map[emp_id] = existing + stats["skipped"] += 1 + else: + staff = Staff(employee_id=emp_id, **staff_data) + staff.save() + staff_map[emp_id] = staff + stats["created"] += 1 + + if idx % 500 == 0: + self.stdout.write(f" Processed {idx}/{len(df_ar)}...") + + except Exception as e: + self.stdout.write(self.style.ERROR(f" [{idx}] Error: {e}")) + stats["errors"] += 1 + + self.stdout.write("\nLinking managers...") + manager_lookup = {} + for _, row in df_ar.iterrows(): + eid = row.get("employee_id") + mgr_raw = str(row.get("manager", "")).strip() if pd.notna(row.get("manager")) else "" + if eid and mgr_raw: + manager_lookup[eid] = mgr_raw + + linked = 0 + for emp_id, staff in staff_map.items(): + manager_raw = manager_lookup.get(emp_id, "") + if not manager_raw: + continue + m = re.match(r"^(\d+)\s*-", manager_raw) + if not m: + continue + mgr_id = m.group(1) + mgr = staff_map.get(mgr_id) + if mgr and staff.report_to != mgr: + staff.report_to = mgr + staff.save(update_fields=["report_to"]) + linked += 1 + + self.stdout.write(f" Linked {linked} manager relationships") + + self.stdout.write(self.style.SUCCESS(f"\nDone!")) + self.stdout.write(f" Created: {stats['created']}") + self.stdout.write(f" Updated: {stats['updated']}") + self.stdout.write(f" Skipped: {stats['skipped']}") + self.stdout.write(f" No dept match: {stats['no_dept']}") + self.stdout.write(f" No hospital match: {stats['no_hospital']}") + self.stdout.write(f" Errors: {stats['errors']}") + + def _find_dept(self, hospital, hr_dept_name): + dept = Department.objects.filter( + hospital=hospital, status="active" + ).filter( + Q(hr_name__iexact=hr_dept_name) | Q(name_en__iexact=hr_dept_name) + ).first() + if dept: + return dept + alias_name_en = HR_NAME_ALIASES.get(hr_dept_name) + if alias_name_en: + return Department.objects.filter( + hospital=hospital, name_en__iexact=alias_name_en, status="active" + ).first() + return None diff --git a/apps/organizations/management/commands/populate_location_data.py b/apps/organizations/management/commands/populate_location_data.py index b3a6c5f..4f659a0 100644 --- a/apps/organizations/management/commands/populate_location_data.py +++ b/apps/organizations/management/commands/populate_location_data.py @@ -1,5 +1,5 @@ from django.core.management.base import BaseCommand -from apps.organizations.models import Location, MainSection, SubSection +from apps.organizations.models import LegacyLocation, LegacyMainSection, LegacySubSection class Command(BaseCommand): @@ -1102,13 +1102,13 @@ class Command(BaseCommand): # Create Locations 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"]} ) # Create Main Sections 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"]} ) @@ -1116,14 +1116,14 @@ class Command(BaseCommand): # Clear existing data to prevent old ID conflicts (skip if referenced) try: - SubSection.objects.all().delete() + LegacySubSection.objects.all().delete() except Exception: self.stdout.write(self.style.WARNING("Skipping SubSection deletion - some are referenced by complaints")) for item in subsections_data: subsections_to_create.append( - SubSection( - internal_id=int(item["id"]), # Use 'id' as the internal_id primary key + LegacySubSection( + internal_id=int(item["id"]), name_en=item["name_en"], name_ar=item["name_ar"], location_id=int(item["location_id"]), @@ -1132,7 +1132,7 @@ class Command(BaseCommand): ) # Use bulk_create for speed - SubSection.objects.bulk_create(subsections_to_create, ignore_conflicts=True) + LegacySubSection.objects.bulk_create(subsections_to_create, ignore_conflicts=True) # Bulk Create SubSections # objs = [ # SubSection( diff --git a/apps/organizations/management/commands/seed_staff.py b/apps/organizations/management/commands/seed_staff.py index 5cc14c8..696140b 100644 --- a/apps/organizations/management/commands/seed_staff.py +++ b/apps/organizations/management/commands/seed_staff.py @@ -415,12 +415,12 @@ class Command(BaseCommand): self.style.SUCCESS(f" ✓ Created user: {user.email} (role: {role})") ) - # Send credential email if requested + # Send password reset email if requested if send_email: try: - StaffService.send_credentials_email(staff, password, request) + StaffService.send_password_reset_email(staff, request) self.stdout.write( - self.style.SUCCESS(f" ✓ Sent credential email to: {email}") + self.style.SUCCESS(f" ✓ Sent password reset email to: {email}") ) except Exception as email_error: self.stdout.write( diff --git a/apps/organizations/management/commands/set_hr_names.py b/apps/organizations/management/commands/set_hr_names.py new file mode 100644 index 0000000..77fb7cd --- /dev/null +++ b/apps/organizations/management/commands/set_hr_names.py @@ -0,0 +1,64 @@ +from django.core.management.base import BaseCommand +from apps.organizations.models import Department + +HR_NAME_MAP = { + "Anesthesia And Or": "Anesthesia Department", + "Critical Care": "Critical Care Department", + "Dermatology": "Dermatology Department", + "Facility Management & Maintenance": "Facility Management & Maintenance Department", + "Infection Control": "Infection Control Department", + "Information Technology": "Information Technology Department", + "Internal Medicine": "Internal Medicine Department", + "Laboratory And Blood Bank": "Laboratory Department", + "Medical Ancillary Services": "Medical Ancillary Services Department", + "Obstetrics And Gynecology": "Obstetrics & Gynecology Department", + "Oncology": "ONCOLOGY Department", + "Ophthalmology": "Ophthalmology Department", + "Radiology": "Radiology Department", + "Dentistry": "Dental Department", + "Surgeries": "Surgery Department", + "Pediatrics": "Pediatric Department", + "Emergency Department": "Emergency Medicine Department", + "Accident And Emergency": "Emergency Medicine Department", + "Human Resource": "HR Department", + "Support Services": "Support Services Department", + "Finance Department": "Financial Collection & Claims Department", + "Corporate Administration": "Executive Administration", + "Senior Management Offices": "Executive Administration", + "Patient Relations & Patient Experience Department": "Patient Experience Department", + "Porter Department": "Security Department", + "Transportation Department": "Security Department", + "Corporate Communication Department": "Patient Experience Department", + "Marketing Department": "Patient Experience Department", + "Supply Chain": "Facility Management & Maintenance Department", + "Pharmacy Warehouse Alaziziyah": "Pharmacy Department", + "Family Medicine": "Outpatient Department", + "Business Development": "Executive Administration", + "Continuous Medical Education Managment": "Medical Administration", + "Academic Education And Training Affairs": "Medical Administration", + "Innovation Communication Management": "Medical Administration", + "Transformation And Change Management": "Medical Administration", + "Talent Acquisition Department": "HR Department", + "Internal Audit": "Financial Collection & Claims Department", + "Legal Affairs Department": "Executive Administration", + "Cybersecurity Management": "Information Technology Department", + "Rehabilitation Center": "Outpatient Department", +} + + +class Command(BaseCommand): + help = "Set hr_name on Department records from HR system name mapping" + + def handle(self, *args, **options): + updated = 0 + for hr_name, name_en in HR_NAME_MAP.items(): + depts = Department.objects.filter( + name_en__iexact=name_en, status="active", hr_name="" + ) + for dept in depts: + dept.hr_name = hr_name + dept.save(update_fields=["hr_name"]) + updated += 1 + self.stdout.write(f" {dept.code}: hr_name={repr(hr_name)}") + + self.stdout.write(self.style.SUCCESS(f"\nUpdated {updated} departments")) diff --git a/apps/organizations/management/commands/wipe_dev_data.py b/apps/organizations/management/commands/wipe_dev_data.py new file mode 100644 index 0000000..0a8f33a --- /dev/null +++ b/apps/organizations/management/commands/wipe_dev_data.py @@ -0,0 +1,123 @@ +from django.core.management.base import BaseCommand +from django.db import connection + + +class Command(BaseCommand): + help = "Wipe all dev data: complaints, observations, appreciation, feedback, staff, departments, sections, patients, integrations" + + def add_arguments(self, parser): + parser.add_argument("--confirm", action="store_true", help="Confirm data wipe") + parser.add_argument("--keep-org", action="store_true", help="Keep hospitals, departments, sections, staff") + + def handle(self, *args, **options): + if not options["confirm"]: + self.stdout.write(self.style.ERROR("Add --confirm to confirm data wipe")) + return + + keep_org = options.get("keep_org", False) + + truncate_order = [ + "complaints_complaintupdate", + "complaints_complaintattachment", + "complaints_complaintinvolvedstaff", + "complaints_complaintinvolveddepartment", + "complaints_complaintadverseaction_involved_staff", + "complaints_complaintadverseaction", + "complaints_complaintadverseactionattachment", + "complaints_complaintcommunication", + "complaints_complaintexplanation", + "complaints_explanationattachment", + "complaints_complaintmeeting", + "complaints_complaintprinteraction", + "complaints_departmentmanagerreview", + "complaints_managerreviewanswer", + "complaints_complaint", + "complaints_inquiryattachment", + "complaints_inquiryexplanation", + "complaints_inquiryexplanationattachment", + "complaints_inquiryupdate", + "complaints_inquiry", + "complaints_governmentticket", + "complaints_patientcomplaintsession", + "complaints_oncalladminschedule", + "complaints_oncalladmin", + "observations_observationattachment", + "observations_observationnote", + "observations_observationstatuslog", + "observations_observation", + "appreciation_userbadge", + "appreciation_appreciationbadge", + "appreciation_appreciationstats", + "appreciation_appreciation", + "feedback_feedbackattachment", + "feedback_feedbackresponse", + "feedback_commentactionplan", + "feedback_commentimport", + "feedback_patientcomment", + "feedback_feedback", + "dashboard_complaintrequest", + "dashboard_escalatedcomplaintlog", + "dashboard_evaluationnote", + "dashboard_inquirydetail", + "dashboard_reportcompletion", + "analytics_kpivalue", + "analytics_kpireportdepartmentbreakdown", + "analytics_kpireportlocationbreakdown", + "analytics_kpireportmonthlydata", + "analytics_kpireportsourcebreakdown", + "analytics_kpireport", + "analytics_kpi", + "surveys_surveyinstance", + "physicians_physicianindividualrating", + "physicians_physicianmonthlyrating", + "rca_rcaattachment", + "rca_rcacorrectiveaction", + "rca_rcanote", + "rca_rcarootcause", + "rca_rcastatuslog", + "rca_rootcauseanalysis", + "projects_qiproject_related_actions", + "projects_qiproject_team_members", + "projects_qiprojecttask", + "projects_pdcaphase", + "projects_focusphase", + "projects_qiproject", + "organizations_staffsubsection", + "organizations_staffsection", + "organizations_staff", + "organizations_section", + "organizations_department", + "organizations_legacyhierarchymapping", + "organizations_subsection", + "organizations_mainsection", + "organizations_location", + "organizations_patient", + "integrations_hispatientvisit", + "integrations_histestpatient", + "integrations_histestvisit", + ] + + org_tables = { + "organizations_staff", + "organizations_staffsection", + "organizations_staffsubsection", + "organizations_section", + "organizations_department", + } + + with connection.cursor() as cursor: + for table in truncate_order: + if keep_org and table in org_tables: + self.stdout.write(f" SKIP (keep-org): {table}") + continue + for table in truncate_order: + try: + cursor.execute(f'TRUNCATE TABLE "{table}" CASCADE') + self.stdout.write(f" Truncated: {table}") + except Exception as e: + self.stdout.write(self.style.WARNING(f" SKIP {table}: {e}")) + + cursor.execute("UPDATE accounts_user SET department_id = NULL") + self.stdout.write(" Nullified: accounts_user.department_id") + + self.stdout.write(self.style.SUCCESS("\nDone. All dev data wiped.")) diff --git a/apps/organizations/migrations/0002_rename_respondent_to_champion.py b/apps/organizations/migrations/0002_rename_respondent_to_champion.py new file mode 100644 index 0000000..62a5f57 --- /dev/null +++ b/apps/organizations/migrations/0002_rename_respondent_to_champion.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.1 on 2026-05-14 15:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='department', + old_name='respondent', + new_name='champion', + ), + ] diff --git a/apps/organizations/migrations/0003_alter_department_champion.py b/apps/organizations/migrations/0003_alter_department_champion.py new file mode 100644 index 0000000..c0185b3 --- /dev/null +++ b/apps/organizations/migrations/0003_alter_department_champion.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.1 on 2026-05-17 15:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0002_rename_respondent_to_champion'), + ] + + operations = [ + migrations.AlterField( + model_name='department', + name='champion', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='champion_departments', to='organizations.staff'), + ), + ] diff --git a/apps/organizations/migrations/0004_legacylocation_legacymainsection_and_more.py b/apps/organizations/migrations/0004_legacylocation_legacymainsection_and_more.py new file mode 100644 index 0000000..ac3ab2a --- /dev/null +++ b/apps/organizations/migrations/0004_legacylocation_legacymainsection_and_more.py @@ -0,0 +1,176 @@ +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0003_alter_department_champion'), + ] + + operations = [ + migrations.RenameModel( + old_name='Location', + new_name='LegacyLocation', + ), + migrations.RenameModel( + old_name='MainSection', + new_name='LegacyMainSection', + ), + migrations.RenameModel( + old_name='SubSection', + new_name='LegacySubSection', + ), + migrations.AlterModelOptions( + name='department', + options={'ordering': ['hospital', 'area', 'name_en']}, + ), + migrations.AddField( + model_name='department', + name='area', + field=models.CharField(blank=True, db_index=True, help_text='High-level area (Medical, Administrative, Nursing, Support Services)', max_length=100), + ), + migrations.AddField( + model_name='department', + name='champion_email', + field=models.EmailField(blank=True, help_text='Fallback email for champion', max_length=200), + ), + migrations.AddField( + model_name='department', + name='deputy_manager', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_deputy_manager', to='organizations.staff'), + ), + migrations.AddField( + model_name='department', + name='deputy_supervisor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_deputy_supervisor', to='organizations.staff'), + ), + migrations.AddField( + model_name='department', + name='floor', + field=models.CharField(blank=True, help_text='Floor (e.g., GF, 1st Floor, Basement)', max_length=50), + ), + migrations.AddField( + model_name='department', + name='location_type', + field=models.CharField(blank=True, help_text='Location type (OP, IP, ER, GENERAL)', max_length=20), + ), + migrations.AddField( + model_name='department', + name='main_section', + field=models.CharField(blank=True, help_text='Main section name (often same as department name)', max_length=200), + ), + migrations.AddField( + model_name='department', + name='manager_1st', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_manager_1st', to='organizations.staff'), + ), + migrations.AddField( + model_name='department', + name='manager_2nd', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_manager_2nd', to='organizations.staff'), + ), + migrations.AddField( + model_name='department', + name='manager_3rd', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_manager_3rd', to='organizations.staff'), + ), + migrations.AddField( + model_name='department', + name='old_name_ar', + field=models.CharField(blank=True, help_text='Legacy Arabic name from Excel col X', max_length=200), + ), + migrations.AddField( + model_name='department', + name='old_name_en', + field=models.CharField(blank=True, help_text='Legacy English name from Excel col W', max_length=200), + ), + migrations.AddField( + model_name='department', + name='sub_location', + field=models.CharField(blank=True, help_text='Detailed location (e.g., OPD5/GATE1)', max_length=200), + ), + migrations.AddField( + model_name='department', + name='supervisor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dept_supervisor', to='organizations.staff'), + ), + migrations.AlterField( + model_name='department', + name='category', + field=models.CharField(blank=True, choices=[('nursing', 'Nursing'), ('support_services', 'Support Services'), ('medical', 'Medical'), ('non_medical', 'Non-Medical'), ('administrative', 'Administrative')], db_index=True, default='', max_length=30), + ), + migrations.AlterField( + model_name='staff', + name='department_type', + field=models.CharField(blank=True, choices=[('nursing', 'Nursing'), ('support_services', 'Support Services'), ('medical', 'Medical'), ('non_medical', 'Non-Medical'), ('administrative', 'Administrative')], db_index=True, default='', max_length=30), + ), + migrations.CreateModel( + name='OrgSubSection', + 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)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('code', models.CharField(blank=True, max_length=50)), + ('location_type', models.CharField(blank=True, max_length=20)), + ('sub_location', models.CharField(blank=True, max_length=200)), + ('floor', models.CharField(blank=True, max_length=50)), + ('old_name_en', models.CharField(blank=True, max_length=200)), + ('old_name_ar', models.CharField(blank=True, max_length=200)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='org_subsections', to='organizations.department')), + ('point_of_contact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poc_org_subsections', to='organizations.staff')), + ], + options={ + 'verbose_name': 'Section', + 'verbose_name_plural': 'Sections', + 'ordering': ['department', 'name_en'], + 'unique_together': {('department', 'code')}, + }, + ), + migrations.CreateModel( + name='OrgSubSubSection', + 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)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('code', models.CharField(blank=True, max_length=50)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('subsection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sub_subsections', to='organizations.orgsubsection')), + ], + options={ + 'verbose_name': 'Sub-Section', + 'verbose_name_plural': 'Sub-Sections', + 'ordering': ['subsection', 'name_en'], + 'unique_together': {('subsection', 'code')}, + }, + ), + migrations.CreateModel( + name='LegacyHierarchyMapping', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_location_ar', models.CharField(db_index=True, max_length=200)), + ('old_main_section_ar', models.CharField(db_index=True, max_length=200)), + ('old_subsection_ar', models.CharField(db_index=True, max_length=200)), + ('old_location_en', models.CharField(blank=True, max_length=200)), + ('old_main_section_en', models.CharField(blank=True, max_length=200)), + ('old_subsection_en', models.CharField(blank=True, max_length=200)), + ('main_section', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_mappings', to='organizations.department')), + ('subsection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_mappings', to='organizations.orgsubsection')), + ('sub_subsection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='legacy_mappings', to='organizations.orgsubsubsection')), + ], + ), + migrations.AddIndex( + model_name='legacyhierarchymapping', + index=models.Index(fields=['old_location_ar', 'old_main_section_ar', 'old_subsection_ar'], name='organizatio_old_loc_920b0f_idx'), + ), + migrations.AlterUniqueTogether( + name='legacyhierarchymapping', + unique_together={('old_location_ar', 'old_main_section_ar', 'old_subsection_ar')}, + ), + ] diff --git a/apps/organizations/migrations/0005_alter_legacylocation_table_and_more.py b/apps/organizations/migrations/0005_alter_legacylocation_table_and_more.py new file mode 100644 index 0000000..9c0ad68 --- /dev/null +++ b/apps/organizations/migrations/0005_alter_legacylocation_table_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 6.0.1 on 2026-05-28 19:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0004_legacylocation_legacymainsection_and_more'), + ] + + operations = [ + migrations.AlterModelTable( + name='legacylocation', + table='organizations_location', + ), + migrations.AlterModelTable( + name='legacymainsection', + table='organizations_mainsection', + ), + migrations.AlterModelTable( + name='legacysubsection', + table='organizations_subsection', + ), + ] diff --git a/apps/organizations/migrations/0006_alter_department_code_alter_orgsubsection_code_and_more.py b/apps/organizations/migrations/0006_alter_department_code_alter_orgsubsection_code_and_more.py new file mode 100644 index 0000000..7c3898b --- /dev/null +++ b/apps/organizations/migrations/0006_alter_department_code_alter_orgsubsection_code_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-05-30 06:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0005_alter_legacylocation_table_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='department', + name='code', + field=models.CharField(db_index=True, max_length=100), + ), + migrations.AlterField( + model_name='orgsubsection', + name='code', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name='orgsubsubsection', + name='code', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/apps/organizations/migrations/0007_remove_department_area_alter_department_location_type_and_more.py b/apps/organizations/migrations/0007_remove_department_area_alter_department_location_type_and_more.py new file mode 100644 index 0000000..34c8ba5 --- /dev/null +++ b/apps/organizations/migrations/0007_remove_department_area_alter_department_location_type_and_more.py @@ -0,0 +1,49 @@ +# Phase 1: Remove area field, add LocationType choices to location_type + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0006_alter_department_code_alter_orgsubsection_code_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='department', + name='area', + ), + migrations.AlterField( + model_name='department', + name='location_type', + field=models.CharField( + blank=True, + choices=[ + ('OP', 'Outpatient'), + ('IP', 'Inpatient'), + ('ER', 'Emergency'), + ('GENERAL', 'General'), + ], + help_text='Location type (OP, IP, ER, GENERAL)', + max_length=20, + ), + ), + migrations.AlterField( + model_name='department', + name='category', + field=models.CharField( + blank=True, + choices=[ + ('medical', 'Medical'), + ('non_medical', 'Non-Medical'), + ('nursing', 'Nursing'), + ('support_services', 'Support Services'), + ('administrative', 'Administrative'), + ], + db_index=True, + default='', + max_length=30, + ), + ), + ] diff --git a/apps/organizations/migrations/0008_rename_orgsubsection_to_section_add_champion.py b/apps/organizations/migrations/0008_rename_orgsubsection_to_section_add_champion.py new file mode 100644 index 0000000..48d46a2 --- /dev/null +++ b/apps/organizations/migrations/0008_rename_orgsubsection_to_section_add_champion.py @@ -0,0 +1,59 @@ +# Phase 2: Rename OrgSubSection to Section, add champion, remove point_of_contact + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0007_remove_department_area_alter_department_location_type_and_more'), + ] + + operations = [ + migrations.RenameModel( + old_name='OrgSubSection', + new_name='Section', + ), + migrations.AlterModelOptions( + name='section', + options={'ordering': ['department', 'name_en'], 'verbose_name': 'Section', 'verbose_name_plural': 'Sections'}, + ), + migrations.AlterField( + model_name='section', + name='location_type', + field=models.CharField( + blank=True, + choices=[ + ('OP', 'Outpatient'), + ('IP', 'Inpatient'), + ('ER', 'Emergency'), + ('GENERAL', 'General'), + ], + max_length=20, + ), + ), + migrations.RemoveField( + model_name='section', + name='point_of_contact', + ), + migrations.AddField( + model_name='section', + name='champion', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name='champion_sections', + to='organizations.staff', + ), + ), + migrations.AlterField( + model_name='section', + name='department', + field=models.ForeignKey( + on_delete=models.CASCADE, + related_name='sections', + to='organizations.department', + ), + ), + ] diff --git a/apps/organizations/migrations/0009_remove_sub_subsection_fields.py b/apps/organizations/migrations/0009_remove_sub_subsection_fields.py new file mode 100644 index 0000000..eb3358c --- /dev/null +++ b/apps/organizations/migrations/0009_remove_sub_subsection_fields.py @@ -0,0 +1,18 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0008_rename_orgsubsection_to_section_add_champion'), + ] + + operations = [ + migrations.RemoveField( + model_name='legacyhierarchymapping', + name='sub_subsection', + ), + migrations.DeleteModel( + name='OrgSubSubSection', + ), + ] diff --git a/apps/organizations/migrations/0010_section_add_roles_display_names.py b/apps/organizations/migrations/0010_section_add_roles_display_names.py new file mode 100644 index 0000000..6ff2833 --- /dev/null +++ b/apps/organizations/migrations/0010_section_add_roles_display_names.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.1 on 2026-06-07 01:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0009_remove_sub_subsection_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='department', + options={'ordering': ['hospital', 'category', 'name_en']}, + ), + migrations.AddField( + model_name='section', + name='deputy_supervisor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deputy_supervised_sections', to='organizations.staff'), + ), + migrations.AddField( + model_name='section', + name='display_name_ar', + field=models.CharField(blank=True, help_text='Patient-facing display name (Arabic)', max_length=255), + ), + migrations.AddField( + model_name='section', + name='display_name_en', + field=models.CharField(blank=True, help_text='Patient-facing display name (English)', max_length=255), + ), + migrations.AddField( + model_name='section', + name='supervisor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supervised_sections', to='organizations.staff'), + ), + migrations.AlterField( + model_name='staff', + name='department_type', + field=models.CharField(blank=True, choices=[('medical', 'Medical'), ('non_medical', 'Non-Medical'), ('nursing', 'Nursing'), ('support_services', 'Support Services'), ('administrative', 'Administrative')], db_index=True, default='', max_length=30), + ), + migrations.AlterField( + model_name='staffsection', + name='department', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='staff_sections', to='organizations.department'), + ), + migrations.AlterModelTable( + name='section', + table='organizations_section', + ), + ] diff --git a/apps/organizations/migrations/0011_area_department_area.py b/apps/organizations/migrations/0011_area_department_area.py new file mode 100644 index 0000000..1ce6451 --- /dev/null +++ b/apps/organizations/migrations/0011_area_department_area.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.1 on 2026-06-07 04:37 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0010_section_add_roles_display_names'), + ] + + operations = [ + migrations.CreateModel( + name='Area', + 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)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('code', models.CharField(blank=True, max_length=100)), + ('location_type', models.CharField(blank=True, choices=[('OP', 'Outpatient'), ('IP', 'Inpatient'), ('ER', 'Emergency'), ('GENERAL', 'General')], help_text='Location type (OP/IP/ER/GO)', max_length=20)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], default='active', max_length=20)), + ('hospital', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='areas', to='organizations.hospital')), + ], + options={ + 'unique_together': {('hospital', 'code')}, + }, + ), + migrations.AddField( + model_name='department', + name='area', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='departments', to='organizations.area'), + ), + ] diff --git a/apps/organizations/migrations/0012_department_hr_name.py b/apps/organizations/migrations/0012_department_hr_name.py new file mode 100644 index 0000000..ce7a22f --- /dev/null +++ b/apps/organizations/migrations/0012_department_hr_name.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-06-07 20:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0011_area_department_area'), + ] + + operations = [ + migrations.AddField( + model_name='department', + name='hr_name', + field=models.CharField(blank=True, help_text='Department name from HR system (employees file). If empty, name_en matches HR exactly.', max_length=200, verbose_name='HR System Name'), + ), + ] diff --git a/apps/organizations/migrations/0013_add_subsection_model.py b/apps/organizations/migrations/0013_add_subsection_model.py new file mode 100644 index 0000000..1bdb9fd --- /dev/null +++ b/apps/organizations/migrations/0013_add_subsection_model.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.1 on 2026-06-08 11:01 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0012_department_hr_name'), + ] + + operations = [ + migrations.CreateModel( + name='SubSection', + 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)), + ('name_en', models.CharField(max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200)), + ('code', models.CharField(blank=True, max_length=100)), + ('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending'), ('completed', 'Completed'), ('cancelled', 'Cancelled')], db_index=True, default='active', max_length=20)), + ('champion', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='champion_subsections', to='organizations.staff')), + ('section', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subsections', to='organizations.section')), + ], + options={ + 'verbose_name': 'SubSection', + 'verbose_name_plural': 'SubSections', + 'db_table': 'organizations_subsection_new', + 'ordering': ['section', 'name_en'], + 'unique_together': {('section', 'code')}, + }, + ), + ] diff --git a/apps/organizations/migrations/0014_remove_department_manager_1st.py b/apps/organizations/migrations/0014_remove_department_manager_1st.py new file mode 100644 index 0000000..ff34608 --- /dev/null +++ b/apps/organizations/migrations/0014_remove_department_manager_1st.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.1 on 2026-06-11 11:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0013_add_subsection_model'), + ] + + operations = [ + migrations.RemoveField( + model_name='department', + name='manager_1st', + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index f2fb1d7..d72cf47 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -148,21 +148,54 @@ class Hospital(UUIDModel, TimeStampedModel): return self.display_name_ar or self.name_ar or self.get_display_name() -class Department(UUIDModel, TimeStampedModel): - """Department within a hospital""" +class DepartmentCategory(models.TextChoices): + MEDICAL = "medical", _("Medical") + NON_MEDICAL = "non_medical", _("Non-Medical") + NURSING = "nursing", _("Nursing") + SUPPORT_SERVICES = "support_services", _("Support Services") + ADMINISTRATIVE = "administrative", _("Administrative") - class DepartmentCategory(models.TextChoices): - NURSING = "nursing", _("Nursing") - SUPPORT_SERVICES = "support_services", _("Support Services") - MEDICAL = "medical", _("Medical") - NON_MEDICAL = "non_medical", _("Non-Medical") + +class LocationType(models.TextChoices): + OP = "OP", _("Outpatient") + IP = "IP", _("Inpatient") + ER = "ER", _("Emergency") + GENERAL = "GENERAL", _("General") + + +class Area(UUIDModel, TimeStampedModel): + hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name="areas") + name_en = models.CharField(max_length=200) + name_ar = models.CharField(max_length=200, blank=True) + code = models.CharField(max_length=100, blank=True) + location_type = models.CharField( + max_length=20, choices=LocationType.choices, blank=True, help_text="Location type (OP/IP/ER/GO)" + ) + status = models.CharField(max_length=20, choices=StatusChoices.choices, default="active") + + class Meta: + unique_together = [["hospital", "code"]] + + def __str__(self): + return f"{self.name_en} ({self.hospital.code})" + + +class Department(UUIDModel, TimeStampedModel): + """Department within a hospital, matching the 4th Version Excel.""" hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name="departments") + main_section = models.CharField( + max_length=200, + blank=True, + help_text="Main section name (often same as department name)", + ) + name = models.CharField(max_length=200) name_en = models.CharField(max_length=200, blank=True) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") - code = models.CharField(max_length=50, db_index=True) + hr_name = models.CharField(max_length=200, blank=True, verbose_name="HR System Name", help_text="Department name from HR system (employees file). If empty, name_en matches HR exactly.") + code = models.CharField(max_length=100, db_index=True) category = models.CharField( max_length=30, @@ -172,34 +205,91 @@ class Department(UUIDModel, TimeStampedModel): db_index=True, ) - # Hierarchy parent = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="sub_departments") - # Manager + location_type = models.CharField( + max_length=20, + choices=LocationType.choices, + blank=True, + help_text="Location type (OP, IP, ER, GENERAL)", + ) + sub_location = models.CharField(max_length=200, blank=True, help_text="Detailed location (e.g., OPD5/GATE1)") + floor = models.CharField(max_length=50, blank=True, help_text="Floor (e.g., GF, 1st Floor, Basement)") + area = models.ForeignKey( + Area, on_delete=models.SET_NULL, null=True, blank=True, related_name="departments" + ) + manager = models.ForeignKey( "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_departments" ) - # Respondent (handles inquiries/complaints for this department) - respondent = models.ForeignKey( - "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="respondent_departments" + manager_3rd = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="dept_manager_3rd" + ) + manager_2nd = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="dept_manager_2nd" + ) + deputy_manager = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="dept_deputy_manager" + ) + supervisor = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="dept_supervisor" + ) + deputy_supervisor = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="dept_deputy_supervisor" ) - # Contact + champion = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="champion_departments" + ) + champion_email = models.EmailField(max_length=200, blank=True, help_text="Fallback email for champion") + + old_name_en = models.CharField(max_length=200, blank=True, help_text="Legacy English name from Excel col W") + old_name_ar = models.CharField(max_length=200, blank=True, help_text="Legacy Arabic name from Excel col X") + phone = models.CharField(max_length=20, blank=True) email = models.EmailField(blank=True) location = models.CharField(max_length=200, blank=True, help_text="Building/Floor/Room") - # Status status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) class Meta: - ordering = ["hospital", "name"] + ordering = ["hospital", "category", "name_en"] unique_together = [["hospital", "code"]] def __str__(self): return self.name_en or self.name + ROLE_FIELDS = [ + ("champion", "Champion"), + ("manager_2nd", "2nd Manager"), + ("manager_3rd", "3rd Manager"), + ("deputy_manager", "Deputy Manager"), + ("supervisor", "Supervisor"), + ("deputy_supervisor", "Deputy Supervisor"), + ] + + def get_role_holders(self): + holders = [] + for field_name, role_label in self.ROLE_FIELDS: + staff = getattr(self, field_name, None) + if staff: + holders.append({ + "staff": staff, + "staff_id": str(staff.id), + "name": staff.get_full_name(), + "email": staff.email or (staff.user.email if staff.user else None), + "role_field": field_name, + "role_label": role_label, + }) + return holders + + def is_valid_contact_person(self, staff_id): + for holder in self.get_role_holders(): + if str(holder["staff_id"]) == str(staff_id): + return holder + return None + def get_localized_name(self): from django.utils.translation import get_language @@ -230,7 +320,7 @@ class Staff(UUIDModel, TimeStampedModel): staff_type = models.CharField(max_length=20, choices=StaffType.choices) department_type = models.CharField( max_length=30, - choices=Department.DepartmentCategory.choices, + choices=DepartmentCategory.choices, blank=True, default="", db_index=True, @@ -533,7 +623,7 @@ class Patient(UUIDModel, TimeStampedModel): class StaffSection(UUIDModel, TimeStampedModel): """Section within a department (for staff organization)""" - department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name="sections") + department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name="staff_sections") name = models.CharField(max_length=200) name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") @@ -578,13 +668,16 @@ class StaffSubsection(UUIDModel, TimeStampedModel): return f"{self.section.department.name} - {self.section.name} - {self.name}" -class Location(models.Model): - id = models.IntegerField(primary_key=True) # Using your specific IDs (48, 49, etc.) +class LegacyLocation(models.Model): + id = models.IntegerField(primary_key=True) name_ar = models.CharField(max_length=100) name_en = models.CharField(max_length=100) ACTIVE_IDS = [48, 49, 82, 110] + class Meta: + db_table = "organizations_location" + @classmethod def active_locations(cls): return cls.objects.filter(id__in=cls.ACTIVE_IDS).order_by("name_en") @@ -593,25 +686,161 @@ class Location(models.Model): return self.name_en if self.name_en else self.name_ar -class MainSection(models.Model): - id = models.IntegerField(primary_key=True) # Using your specific IDs (1, 2, 3, 4, 5) +class LegacyMainSection(models.Model): + id = models.IntegerField(primary_key=True) name_ar = models.CharField(max_length=100) name_en = models.CharField(max_length=100) + class Meta: + db_table = "organizations_mainsection" + def __str__(self): - # Prefer English name if available, otherwise use Arabic return self.name_en if self.name_en else self.name_ar -class SubSection(models.Model): - internal_id = models.IntegerField(primary_key=True) # The 'value' from HTML +class LegacySubSection(models.Model): + internal_id = models.IntegerField(primary_key=True) name_ar = models.CharField(max_length=255) name_en = models.CharField(max_length=255) - location = models.ForeignKey(Location, on_delete=models.CASCADE, related_name="subsections") - main_section = models.ForeignKey(MainSection, on_delete=models.CASCADE, related_name="subsections") + location = models.ForeignKey(LegacyLocation, on_delete=models.CASCADE, related_name="subsections") + main_section = models.ForeignKey(LegacyMainSection, on_delete=models.CASCADE, related_name="subsections") + + class Meta: + db_table = "organizations_subsection" def __str__(self): - # Prefer English name if available, otherwise use Arabic name = self.name_en if self.name_en else self.name_ar location_name = self.location.name_en if self.location.name_en else self.location.name_ar return f"{name} - {location_name}" + + +class Section(UUIDModel, TimeStampedModel): + """Section within a Department (Excel Col E, e.g., Central Outpatient Pharmacy).""" + + department = models.ForeignKey(Department, on_delete=models.CASCADE, related_name="sections") + + name_en = models.CharField(max_length=200) + name_ar = models.CharField(max_length=200, blank=True) + code = models.CharField(max_length=100, blank=True) + + location_type = models.CharField(max_length=20, choices=LocationType.choices, blank=True) + sub_location = models.CharField(max_length=200, blank=True) + floor = models.CharField(max_length=50, blank=True) + + champion = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="champion_sections" + ) + + supervisor = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="supervised_sections" + ) + deputy_supervisor = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="deputy_supervised_sections" + ) + + display_name_en = models.CharField(max_length=255, blank=True, help_text="Patient-facing display name (English)") + display_name_ar = models.CharField(max_length=255, blank=True, help_text="Patient-facing display name (Arabic)") + + old_name_en = models.CharField(max_length=200, blank=True) + old_name_ar = models.CharField(max_length=200, blank=True) + + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) + + class Meta: + db_table = "organizations_section" + unique_together = [("department", "code")] + ordering = ["department", "name_en"] + verbose_name = "Section" + verbose_name_plural = "Sections" + + def __str__(self): + return f"{self.department.name_en} / {self.name_en}" + + def get_localized_display_name(self): + from django.utils.translation import get_language + if get_language() == "ar" and self.display_name_ar: + return self.display_name_ar + if self.display_name_en: + return self.display_name_en + if get_language() == "ar" and self.name_ar: + return self.name_ar + return self.name_en + + def get_localized_name(self): + from django.utils.translation import get_language + if get_language() == "ar" and self.name_ar: + return self.name_ar + return self.name_en + + +class SubSection(UUIDModel, TimeStampedModel): + """SubSection within a Section (Department -> Section -> SubSection).""" + + section = models.ForeignKey(Section, on_delete=models.CASCADE, related_name="subsections") + + name_en = models.CharField(max_length=200) + name_ar = models.CharField(max_length=200, blank=True) + code = models.CharField(max_length=100, blank=True) + + champion = models.ForeignKey( + "Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="champion_subsections" + ) + + status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True) + + class Meta: + db_table = "organizations_subsection_new" + unique_together = [("section", "code")] + ordering = ["section", "name_en"] + verbose_name = "SubSection" + verbose_name_plural = "SubSections" + + def __str__(self): + return f"{self.section.department.name_en} / {self.section.name_en} / {self.name_en}" + + def get_localized_name(self): + from django.utils.translation import get_language + if get_language() == "ar" and self.name_ar: + return self.name_ar + return self.name_en + + +# Backward compatibility alias +OrgSubSection = Section + +# Deprecation alias for OrgSubSubSection (removed) +OrgSubSubSection = None + + +class LegacyHierarchyMapping(models.Model): + """Maps old complaint location hierarchy (Arabic) to current Department/Section.""" + + old_location_ar = models.CharField(max_length=200, db_index=True) + old_main_section_ar = models.CharField(max_length=200, db_index=True) + old_subsection_ar = models.CharField(max_length=200, db_index=True) + + old_location_en = models.CharField(max_length=200, blank=True) + old_main_section_en = models.CharField(max_length=200, blank=True) + old_subsection_en = models.CharField(max_length=200, blank=True) + + main_section = models.ForeignKey( + Department, on_delete=models.SET_NULL, null=True, blank=True, related_name="legacy_mappings" + ) + subsection = models.ForeignKey( + Section, on_delete=models.SET_NULL, null=True, blank=True, related_name="legacy_mappings" + ) + + class Meta: + unique_together = [("old_location_ar", "old_main_section_ar", "old_subsection_ar")] + indexes = [ + models.Index(fields=["old_location_ar", "old_main_section_ar", "old_subsection_ar"]), + ] + + def __str__(self): + target = self.subsection or self.main_section + return f"{self.old_location_ar} / {self.old_main_section_ar} / {self.old_subsection_ar} → {target}" + + +# Backward-compatible aliases (deprecated - use Legacy* names) +Location = LegacyLocation +MainSection = LegacyMainSection diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index 21fe52b..7243e60 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -4,7 +4,21 @@ Organizations serializers from rest_framework import serializers -from .models import Department, Hospital, Location, MainSection, Organization, Patient, Staff, SubSection +from .models import ( + Area, + Department, + Hospital, + LegacyHierarchyMapping, + LegacyLocation, + LegacyMainSection, + LegacySubSection, + OrgSubSection, + Section, + SubSection, + Organization, + Patient, + Staff, +) class OrganizationSerializer(serializers.ModelSerializer): @@ -87,6 +101,7 @@ class DepartmentSerializer(serializers.ModelSerializer): "hospital", "hospital_name", "name", + "name_en", "name_ar", "code", "category", @@ -216,9 +231,9 @@ class StaffSerializer(serializers.ModelSerializer): ) # Send email if requested and user was created - if was_created and password and send_email and self.context.get("request"): + if was_created and user and send_email and self.context.get("request"): try: - StaffService.send_credentials_email(staff, password, self.context["request"]) + StaffService.send_password_reset_email(staff, self.context["request"]) except Exception as e: # Log but don't fail if email sending fails pass @@ -255,9 +270,9 @@ class StaffSerializer(serializers.ModelSerializer): ) # Send email if requested and user was created - if was_created and password and send_email and self.context.get("request"): + if was_created and user and send_email and self.context.get("request"): try: - StaffService.send_credentials_email(instance, password, self.context["request"]) + StaffService.send_password_reset_email(instance, self.context["request"]) except Exception as e: pass except ValueError as e: @@ -337,13 +352,13 @@ class PatientListSerializer(serializers.ModelSerializer): fields = ["id", "mrn", "full_name", "national_id_masked", "phone", "email", "primary_hospital_name", "status"] -class LocationSerializer(serializers.ModelSerializer): +class LegacyLocationSerializer(serializers.ModelSerializer): """Location serializer for dropdown""" name = serializers.SerializerMethodField() class Meta: - model = Location + model = LegacyLocation fields = ["id", "name"] def get_name(self, obj): @@ -357,13 +372,13 @@ class LocationSerializer(serializers.ModelSerializer): return obj.name_en if obj.name_en else obj.name_ar -class MainSectionSerializer(serializers.ModelSerializer): +class LegacyMainSectionSerializer(serializers.ModelSerializer): """MainSection serializer for dropdown""" name = serializers.SerializerMethodField() class Meta: - model = MainSection + model = LegacyMainSection fields = ["id", "name"] def get_name(self, obj): @@ -377,7 +392,7 @@ class MainSectionSerializer(serializers.ModelSerializer): return obj.name_en if obj.name_en else obj.name_ar -class SubSectionSerializer(serializers.ModelSerializer): +class LegacySubSectionSerializer(serializers.ModelSerializer): """SubSection serializer for dropdown""" id = serializers.IntegerField(source="internal_id", read_only=True) @@ -386,7 +401,7 @@ class SubSectionSerializer(serializers.ModelSerializer): main_section_name = serializers.SerializerMethodField() class Meta: - model = SubSection + model = LegacySubSection fields = ["id", "name", "location", "main_section", "location_name", "main_section_name"] def get_name(self, obj): @@ -418,3 +433,54 @@ class SubSectionSerializer(serializers.ModelSerializer): if lang == "ar" and obj.main_section.name_ar: return obj.main_section.name_ar return obj.main_section.name_en if obj.main_section.name_en else obj.main_section.name_ar + + +class OrgSubSectionSerializer(serializers.ModelSerializer): + department_name = serializers.SerializerMethodField() + display_name = serializers.SerializerMethodField() + + class Meta: + model = Section + fields = "__all__" + + def get_department_name(self, obj): + return str(obj.department) + + def get_display_name(self, obj): + return obj.get_localized_display_name() + + +# Backward compatibility alias +SectionSerializer = OrgSubSectionSerializer + + +class SubSectionSerializer(serializers.ModelSerializer): + section_name = serializers.SerializerMethodField() + display_name = serializers.SerializerMethodField() + + class Meta: + model = SubSection + fields = "__all__" + + def get_section_name(self, obj): + return str(obj.section) + + def get_display_name(self, obj): + return obj.get_localized_name() + + +class LegacyHierarchyMappingSerializer(serializers.ModelSerializer): + class Meta: + model = LegacyHierarchyMapping + fields = "__all__" + + +# Backward-compatible aliases +LocationSerializer = LegacyLocationSerializer +MainSectionSerializer = LegacyMainSectionSerializer + + +class AreaSerializer(serializers.ModelSerializer): + class Meta: + model = Area + fields = ["id", "hospital", "name_en", "name_ar", "code", "location_type", "status"] diff --git a/apps/organizations/services.py b/apps/organizations/services.py index e43a87e..6165c65 100644 --- a/apps/organizations/services.py +++ b/apps/organizations/services.py @@ -6,11 +6,11 @@ import string from django.contrib.auth import get_user_model from django.template.loader import render_to_string from django.conf import settings -from django.urls import reverse from django.utils import timezone from apps.core.services import AuditService from apps.notifications.services import NotificationService +from apps.accounts.services import PasswordResetTokenService User = get_user_model() @@ -57,10 +57,9 @@ class StaffService: request: HTTP request for audit logging Returns: - tuple: (User instance, was_created: bool, password: str or None) + tuple: (User instance, was_created: bool, password: None) - was_created is True if a new user was created - - was_created is False if an existing user was linked - - password is the generated password for new users, None for linked users + - password is always None because users set passwords through a secure email link Raises: ValueError: If staff already has a user account or has no email @@ -118,13 +117,11 @@ class StaffService: # Create new user account # Generate username (optional, for backward compatibility) username = StaffService.generate_username(staff) - password = StaffService.generate_password() # Create user - email is now the username field - # Note: create_user() already hashes the password, so no need to call set_password() separately user = User.objects.create_user( email=staff.email, - password=password, + password=None, first_name=staff.first_name, last_name=staff.last_name, username=username, # Optional field @@ -162,7 +159,7 @@ class StaffService: } ) - return user, True, password # New user was created with password + return user, True, None # New user was created with no emailed password @staticmethod def link_user_to_staff(staff, user_id, request=None): @@ -248,14 +245,9 @@ class StaffService: return staff @staticmethod - def send_credentials_email(staff, password, request=None): + def send_password_reset_email(staff, request=None): """ - Send login credentials email to staff member using NotificationService. - - Args: - staff: Staff instance - password: Generated password - request: HTTP request for building absolute URLs (optional) + Send a one-time password reset link to a staff member. """ if not staff.email: raise ValueError("Staff member has no email address") @@ -264,50 +256,39 @@ class StaffService: if not user: raise ValueError("Staff member has no user account") - # Build login URL + reset_token = PasswordResetTokenService.create_reset_token(user) if request: - login_url = request.build_absolute_uri(reverse('accounts:login')) + base_url = request.build_absolute_uri("/") else: - from django.contrib.sites.models import Site - try: - site = Site.objects.get_current() - login_url = f"https://{site.domain}{reverse('accounts:login')}" - except: - login_url = settings.LOGIN_URL or '/accounts/login/' + base_url = settings.SITE_URL if hasattr(settings, "SITE_URL") else "http://localhost:8000" + reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token) - # Render email content context = { 'staff': staff, 'user': user, - 'password': password, - 'login_url': login_url, + 'reset_url': reset_url, } - subject = "Your PX360 Account Credentials" + subject = "Set Your PX360 Password" html_message = render_to_string('organizations/emails/staff_credentials.html', context) - - # Create plain text version + plain_message = f"""Welcome to PX360! Dear {staff.get_full_name()}, -Your PX360 account has been created successfully. Below are your login credentials: +Your PX360 account has been created successfully. For your security, no password is sent by email. Username: {user.username} -Password: {password} Email: {staff.email} -Login URL: {login_url} +Set your password here: {reset_url} -Security Notice: Please change your password after your first login for security purposes. - -If you have any questions or need assistance, please contact your system administrator. +This link expires in 24 hours. If you did not request this, please contact your system administrator. Best regards, The PX360 Team """ - # Send email using NotificationService notification_log = NotificationService.send_email( email=staff.email, subject=subject, @@ -315,18 +296,17 @@ The PX360 Team html_message=html_message, related_object=staff, metadata={ - 'notification_type': 'staff_credentials', + 'notification_type': 'staff_password_reset', 'staff_id': str(staff.id), 'user_id': str(user.id), 'username': user.username } ) - # Log the action if request: AuditService.log_from_request( - event_type='other', - description=f"Credentials email sent to {staff.email} for staff member {staff.get_full_name()}", + event_type='password_reset', + description=f"Password reset link sent to {staff.email} for staff member {staff.get_full_name()}", request=request, content_object=staff, metadata={ @@ -336,17 +316,21 @@ The PX360 Team return notification_log + @staticmethod + def send_credentials_email(staff, password=None, request=None): + return StaffService.send_password_reset_email(staff, request) + @staticmethod def reset_password_and_resend_credentials(staff, request=None): """ - Reset user password and resend credentials email. + Reset user password and resend password reset link. Args: staff: Staff instance request: HTTP request for building absolute URLs and audit logging Returns: - tuple: (new_password: str, notification_log: NotificationLog) + tuple: (None, notification_log: NotificationLog) Raises: ValueError: If staff has no user account or no email @@ -357,21 +341,13 @@ The PX360 Team if not staff.email: raise ValueError("Staff member has no email address") - # Generate new password - new_password = StaffService.generate_password() - - # Reset password - staff.user.set_password(new_password) - staff.user.save(update_fields=['password']) - - # Send credentials email - notification_log = StaffService.send_credentials_email(staff, new_password, request) + notification_log = StaffService.send_password_reset_email(staff, request) # Log the action if request: AuditService.log_from_request( event_type='password_reset', - description=f"Password reset and credentials resent to {staff.email} for staff member {staff.get_full_name()}", + description=f"Password reset link resent to {staff.email} for staff member {staff.get_full_name()}", request=request, content_object=staff, metadata={ @@ -380,7 +356,7 @@ The PX360 Team } ) - return new_password, notification_log + return None, notification_log @staticmethod def get_staff_type_role(staff_type): diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py index 04396fd..401245a 100644 --- a/apps/organizations/ui_views.py +++ b/apps/organizations/ui_views.py @@ -9,9 +9,10 @@ from django.views.decorators.csrf import csrf_exempt from django.utils import timezone from django.utils.translation import activate, get_language, gettext as _ +from django.urls import reverse from apps.core.decorators import block_source_user, hospital_admin_required -from .models import Department, Hospital, Organization, Patient, Staff, StaffSection, StaffSubsection +from .models import Department, DepartmentCategory, Hospital, Organization, Patient, Staff, StaffSection, StaffSubsection, Section, SubSection, OrgSubSection from apps.accounts.models import User from .forms import StaffForm, PatientForm @@ -617,14 +618,19 @@ def staff_create(request): if create_user and not staff.user and staff.email: from .services import StaffService + role = request.POST.get("user_role", "staff") + allowed_roles = ["staff", "px_employee", "hospital_admin", "department_manager"] + if role not in allowed_roles: + role = "staff" + try: user_account, was_created, password = StaffService.create_user_for_staff( - staff, role='staff', request=request + staff, role=role, request=request ) - if was_created and password: + if was_created and user_account: try: - StaffService.send_credentials_email(staff, password, request) - messages.success(request, "Staff member created and credentials email sent successfully.") + StaffService.send_password_reset_email(staff, request) + messages.success(request, "Staff member created and password reset email sent successfully.") except Exception as e: messages.warning(request, f"Staff member created but email sending failed: {str(e)}") elif not was_created: @@ -639,11 +645,8 @@ def staff_create(request): from .services import StaffService try: - password = StaffService.generate_password() - staff.user.set_password(password) - staff.user.save() - StaffService.send_credentials_email(staff, password, request) - messages.success(request, "Credentials email sent successfully.") + StaffService.send_password_reset_email(staff, request) + messages.success(request, "Password reset email sent successfully.") except Exception as e: messages.warning(request, f"Email sending failed: {str(e)}") @@ -687,14 +690,19 @@ def staff_update(request, pk): if create_user and not staff.user and staff.email: from .services import StaffService + role = request.POST.get("user_role", "staff") + allowed_roles = ["staff", "px_employee", "hospital_admin", "department_manager"] + if role not in allowed_roles: + role = "staff" + try: user_account, was_created, password = StaffService.create_user_for_staff( - staff, role='staff', request=request + staff, role=role, request=request ) - if was_created and password: + if was_created and user_account: try: - StaffService.send_credentials_email(staff, password, request) - messages.success(request, "User account created and credentials email sent.") + StaffService.send_password_reset_email(staff, request) + messages.success(request, "User account created and password reset email sent.") except Exception as e: messages.warning(request, f"User account created but email sending failed: {str(e)}") elif not was_created: @@ -1865,7 +1873,7 @@ def department_list(request): "hospitals": hospitals, "search": search, "category_filter": category_filter, - "categories": Department.DepartmentCategory.choices, + "categories": DepartmentCategory.choices, "can_create": user.is_px_admin() or user.is_hospital_admin(), } return render(request, "organizations/department_list.html", context) @@ -1876,11 +1884,17 @@ def department_detail(request, pk): from .models import Department, Staff from apps.complaints.models import Complaint, ComplaintInvolvedStaff, Inquiry from apps.observations.models import Observation + from apps.appreciation.models import Appreciation + from apps.feedback.models import Feedback, FeedbackType from django.db.models import Q from collections import defaultdict department = get_object_or_404( - Department.objects.select_related("hospital", "manager", "parent", "respondent"), + Department.objects.select_related( + "hospital", "manager", "parent", + "champion", "manager", "manager_2nd", "manager_3rd", + "deputy_manager", "supervisor", "deputy_supervisor", + ), pk=pk, ) @@ -1918,7 +1932,7 @@ def department_detail(request, pk): staff_complaint_counts[staff_id] += 1 complaints = Complaint.objects.filter( - Q(department=department) | Q(involved_departments__department=department) + Q(department=department, sent_to_department=True) | Q(involved_departments__department=department, involved_departments__sent=True) ).select_related( "patient", "assigned_to", "category", "domain", "subcategory_obj", "classification_obj", "staff" ).prefetch_related( @@ -1926,11 +1940,11 @@ def department_detail(request, pk): ).distinct().order_by("-created_at") inquiries = Inquiry.objects.filter( - Q(department=department) | Q(outgoing_department=department) + Q(department=department, sent_to_department=True) | Q(outgoing_department=department) ).select_related("assigned_to", "patient").order_by("-created_at") observations = Observation.objects.filter( - assigned_department=department + assigned_department=department, sent_to_department=True ).select_related("category", "assigned_to").order_by("-created_at") complaint_status_filter = request.GET.get("complaint_status") @@ -1945,6 +1959,22 @@ def department_detail(request, pk): if observation_status_filter: observations = observations.filter(status=observation_status_filter) + appreciations = Appreciation.objects.filter( + department=department + ).select_related("sender", "category").order_by("-created_at") + + suggestions = Feedback.objects.filter( + department=department, feedback_type=FeedbackType.SUGGESTION + ).select_related("assigned_to", "staff").order_by("-created_at") + + appreciation_status_filter = request.GET.get("appreciation_status") + if appreciation_status_filter: + appreciations = appreciations.filter(status=appreciation_status_filter) + + suggestion_status_filter = request.GET.get("suggestion_status") + if suggestion_status_filter: + suggestions = suggestions.filter(status=suggestion_status_filter) + active_tab = request.GET.get("tab", "analytics") stats = { @@ -1955,6 +1985,8 @@ def department_detail(request, pk): "total_complaints": complaints.count(), "total_inquiries": inquiries.count(), "total_observations": observations.count(), + "total_appreciations": appreciations.count(), + "total_suggestions": suggestions.count(), } search_query = request.GET.get("search", "").strip() @@ -1985,7 +2017,7 @@ def department_detail(request, pk): ).exclude(email="").order_by("first_name", "last_name") managers = Staff.objects.filter( - hospital=department.hospital, status="active" + department=department, status="active" ).order_by("first_name", "last_name") pending_actions = [] @@ -1996,24 +2028,51 @@ def department_detail(request, pk): pending_complaint_dept_responses = ComplaintInvolvedDepartment.objects.filter( department=department, forwarded_at__isnull=False, - response_submitted=False, + sent=True, + ).filter( + Q(response_submitted=False, acceptance_status="pending") + | Q(acceptance_status="not_acceptable") + | Q(manager_review_status="rejected") ).select_related("complaint").order_by("-forwarded_at") for pc in pending_complaint_dept_responses: + is_rejected = pc.acceptance_status == "not_acceptable" pending_actions.append({ "type": "complaint_department_response", - "type_label": _("Complaint Response"), + "type_label": _("Complaint Response (Re-submit)") if is_rejected else _("Complaint Response"), "reference": pc.complaint.reference_number or str(pc.complaint.id), "subject": pc.complaint.title or _("No title"), "sla_due_at": None, "is_overdue": False, "url": "#", - "badge_color": "orange", + "badge_color": "red" if is_rejected else "orange", "item_id": str(pc.pk), "item_type": "complaint_involved_department", "complaint_id": str(pc.complaint_id), "department_name": pc.department.name, }) + if user.is_department_manager() or user.is_px_admin() or user.is_hospital_admin(): + pending_manager_reviews = ComplaintInvolvedDepartment.objects.filter( + department=department, + response_submitted=True, + manager_review_status="pending", + ).select_related("complaint").order_by("-response_submitted_at") + for mr in pending_manager_reviews: + pending_actions.append({ + "type": "manager_review", + "type_label": _("Manager Review"), + "reference": mr.complaint.reference_number or str(mr.complaint.id), + "subject": mr.complaint.title or _("No title"), + "sla_due_at": None, + "is_overdue": False, + "url": reverse("organizations:department_manager_review", kwargs={"pk": department.pk, "idept_pk": mr.pk}), + "badge_color": "blue", + "item_id": str(mr.pk), + "item_type": "manager_review", + "complaint_id": str(mr.complaint_id), + "department_name": mr.department.name, + }) + # 2. Complaint Explanations (staff-level) pending_explanations = ComplaintExplanation.objects.filter( complaint__department=department, @@ -2033,6 +2092,7 @@ def department_detail(request, pk): pending_observations = Observation.objects.filter( assigned_department=department, + sent_to_department=True, forwarded_to_dept_at__isnull=False, department_responded_at__isnull=True, ).order_by("dept_response_sla_due_at") @@ -2046,10 +2106,13 @@ def department_detail(request, pk): "is_overdue": obs.dept_response_sla_due_at and obs.dept_response_sla_due_at < dj_tz.now(), "url": "/observations/{}/department-response/".format(obs.pk), "badge_color": "purple", + "item_id": str(obs.pk), + "existing_en": obs.department_response_en or "", + "existing_ar": obs.department_response_ar or "", }) pending_inquiries = Inquiry.objects.filter( - Q(outgoing_department=department) | Q(department=department), + Q(outgoing_department=department) | Q(department=department, sent_to_department=True), transferred_at__isnull=False, department_responded_at__isnull=True, status__in=["open", "in_progress"], @@ -2064,6 +2127,9 @@ def department_detail(request, pk): "is_overdue": inq.dept_response_sla_due_at and inq.dept_response_sla_due_at < dj_tz.now(), "url": "/inquiries/{}/department-response/".format(inq.pk), "badge_color": "cyan", + "item_id": str(inq.pk), + "existing_en": inq.department_response_en or "", + "existing_ar": inq.department_response_ar or "", }) # Standards for this department (global + department-specific) @@ -2091,19 +2157,36 @@ def department_detail(request, pk): "staff_list": staff_list, "staff_head": staff_head, "staff_complaint_counts": staff_complaint_counts, - "complaints": complaints[:50], - "inquiries": inquiries[:50], - "observations": observations[:50], + "complaints": complaints[:5], + "inquiries": inquiries[:5], + "observations": observations[:5], + "appreciations": appreciations[:50], + "suggestions": suggestions[:50], "stats": stats, "active_tab": active_tab, "assignable_staff": assignable_staff, "managers": managers, "standards_data": standards_data, + "org_sections": Section.objects.filter(department=department).select_related("champion", "supervisor", "deputy_supervisor").order_by("name_en"), + "role_data": (lambda d: [ + {"field": "champion", "label": "Champion", "staff": d.champion, "readonly": False}, + {"field": "manager", "label": "Manager", "staff": d.manager, "readonly": False}, + ] + [ + { + "field": field_name, + "label": role_label, + "staff": getattr(d, field_name, None), + "readonly": False, + } + for field_name, role_label in Department.ROLE_FIELDS + if field_name != "champion" + ])(department), "search_query": search_query, "complaint_status_filter": complaint_status_filter, "inquiry_status_filter": inquiry_status_filter, "observation_status_filter": observation_status_filter, "can_assign": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager(), + "can_manage_roles": user.is_px_admin() or user.is_px_employee() or user.is_hospital_admin(), "can_edit": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == department.hospital), "can_respond": ( user.is_px_admin() @@ -2116,6 +2199,921 @@ def department_detail(request, pk): } return render(request, "organizations/department_detail.html", context) +def _check_department_access(user, department): + if user.is_px_admin(): + return True + if user.is_hospital_admin() and user.hospital == department.hospital: + return True + if user.is_champion() and user.department == department: + return True + if user.is_department_manager() and user.department == department: + return True + if user.is_basic_staff() and user.department == department: + return True + if user.is_director() and user.get_directed_departments().filter(id=department.id).exists(): + return True + if ( + user.hospital == department.hospital + and not user.is_source_user() + and not user.is_basic_staff() + and not user.is_department_manager() + and not user.is_director() + ): + return True + return False + + +@login_required +def department_complaints_list(request, pk): + from .models import Department + from apps.complaints.models import Complaint, ComplaintInvolvedDepartment + from django.core.paginator import Paginator + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + user = request.user + if not _check_department_access(user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + queryset = Complaint.objects.filter( + Q(department=department, sent_to_department=True) | Q(involved_departments__department=department, involved_departments__sent=True) + ).select_related( + "patient", "assigned_to", "category", "domain", "subcategory_obj", + "classification_obj", "staff", + ).prefetch_related( + "involved_staff__staff", "involved_departments__department", + ).distinct().order_by("-created_at") + + from django.db.models import Count + base_filter = Q(department=department, sent_to_department=True) | Q(involved_departments__department=department, involved_departments__sent=True) + status_counts = dict( + Complaint.objects.filter(base_filter) + .values("status") + .annotate(count=Count("pk", distinct=True)) + .values_list("status", "count") + ) + + status_filter = request.GET.get("status") + severity_filter = request.GET.get("severity", "") + if status_filter: + queryset = queryset.filter(status=status_filter) + if severity_filter: + queryset = queryset.filter(severity=severity_filter) + + search_query = request.GET.get("search", "").strip() + if search_query: + queryset = queryset.filter( + Q(reference_number__icontains=search_query) | Q(title__icontains=search_query) + ) + + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + + page_size = int(request.GET.get("page_size", 25)) + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(request.GET.get("page", 1)) + + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or (user.is_champion() and user.department == department) + ) + + complaint_dept_map = {} + if can_respond: + complaint_ids = [c.pk for c in page_obj.object_list] + for idept in ComplaintInvolvedDepartment.objects.filter( + department=department, complaint_id__in=complaint_ids, sent=True, response_submitted=False + ): + complaint_dept_map[idept.complaint_id] = idept + for idept in ComplaintInvolvedDepartment.objects.filter( + department=department, complaint_id__in=complaint_ids, sent=True, + response_submitted=True, manager_review_status="pending", + ): + if idept.complaint_id not in complaint_dept_map: + complaint_dept_map[idept.complaint_id] = idept + + context = { + "department": department, + "page_obj": page_obj, + "complaints": page_obj, + "status_filter": status_filter, + "severity_filter": severity_filter or "", + "search_query": search_query, + "date_from": date_from or "", + "date_to": date_to or "", + "can_respond": can_respond, + "complaint_dept_map": complaint_dept_map, + "status_choices": Complaint.Status.choices if hasattr(Complaint, 'Status') else [], + "list_stats": status_counts, + "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed")}, + } + return render(request, "organizations/department_complaints.html", context) + + +@login_required +def department_inquiries_list(request, pk): + from .models import Department + from apps.complaints.models import Inquiry + from django.core.paginator import Paginator + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + user = request.user + if not _check_department_access(user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + queryset = Inquiry.objects.filter( + Q(department=department, sent_to_department=True) | Q(outgoing_department=department) + ).select_related("assigned_to", "patient").order_by("-created_at") + + from django.db.models import Count + list_stats = queryset.values("status").annotate(count=Count("pk")) + status_counts = {s["status"]: s["count"] for s in list_stats} + + status_filter = request.GET.get("status") + if status_filter: + queryset = queryset.filter(status=status_filter) + + priority_filter = request.GET.get("priority") + if priority_filter: + queryset = queryset.filter(priority=priority_filter) + + search_query = request.GET.get("search", "").strip() + if search_query: + queryset = queryset.filter( + Q(reference_number__icontains=search_query) + | Q(subject__icontains=search_query) + | Q(contact_name__icontains=search_query) + ) + + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or (user.is_champion() and user.department == department) + ) + + page_size = int(request.GET.get("page_size", 25)) + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(request.GET.get("page", 1)) + + context = { + "department": department, + "page_obj": page_obj, + "inquiries": page_obj, + "status_filter": status_filter, + "priority_filter": priority_filter or "", + "search_query": search_query, + "date_from": date_from or "", + "date_to": date_to or "", + "can_respond": can_respond, + "list_stats": status_counts, + "status_labels": {"open": _("Open"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed")}, + } + return render(request, "organizations/department_inquiries.html", context) + + +@login_required +def department_observations_list(request, pk): + from .models import Department + from apps.observations.models import Observation + from django.core.paginator import Paginator + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + user = request.user + if not _check_department_access(user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + queryset = Observation.objects.filter( + assigned_department=department, sent_to_department=True + ).select_related("category", "assigned_to").order_by("-created_at") + + from django.db.models import Count + list_stats = queryset.values("status").annotate(count=Count("pk")) + status_counts = {s["status"]: s["count"] for s in list_stats} + + status_filter = request.GET.get("status") + if status_filter: + queryset = queryset.filter(status=status_filter) + + severity_filter = request.GET.get("severity") + if severity_filter: + queryset = queryset.filter(severity=severity_filter) + + search_query = request.GET.get("search", "").strip() + if search_query: + queryset = queryset.filter( + Q(tracking_code__icontains=search_query) | Q(title__icontains=search_query) + ) + + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or (user.is_champion() and user.department == department) + ) + + page_size = int(request.GET.get("page_size", 25)) + paginator = Paginator(queryset, page_size) + page_obj = paginator.get_page(request.GET.get("page", 1)) + + context = { + "department": department, + "page_obj": page_obj, + "observations": page_obj, + "status_filter": status_filter, + "severity_filter": severity_filter or "", + "search_query": search_query, + "date_from": date_from or "", + "date_to": date_to or "", + "can_respond": can_respond, + "list_stats": status_counts, + "status_labels": {"new": _("New"), "triaged": _("Triaged"), "in_progress": _("In Progress"), "resolved": _("Resolved"), "closed": _("Closed")}, + } + return render(request, "organizations/department_observations.html", context) + + +@login_required +def department_complaint_detail(request, pk, cpk): + from .models import Department + from apps.complaints.models import Complaint, ComplaintInvolvedDepartment + from django.contrib.contenttypes.models import ContentType + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + if not _check_department_access(request.user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + complaint = get_object_or_404( + Complaint.objects.select_related( + "patient", "hospital", "department", "staff", "assigned_to", + "category", "domain", "subcategory_obj", "classification_obj", + "source", "legacy_location", "legacy_main_section", "legacy_subsection", + ).prefetch_related( + "involved_staff__staff", "involved_departments__department", + ), + pk=cpk, + ) + + belongs = ( + (complaint.department_id == department.pk and complaint.sent_to_department) + or complaint.involved_departments.filter(department=department, sent=True).exists() + ) + if not belongs: + messages.error(request, _("This complaint does not belong to this department.")) + return redirect("organizations:department_complaints_list", pk=pk) + + user = request.user + involved_dept = complaint.involved_departments.filter(department=department, sent=True).first() + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or (user.is_champion() and user.department == department) + ) and involved_dept and not involved_dept.response_submitted + + can_manager_review = ( + (user.is_department_manager() and user.department == department) + or user.is_px_admin() + or user.is_hospital_admin() + ) and involved_dept and involved_dept.response_submitted and involved_dept.manager_review_status == "pending" + + taxonomy = [] + if complaint.domain: + taxonomy.append(complaint.domain.get_localized_name()) + if complaint.category: + taxonomy.append(complaint.category.get_localized_name()) + if complaint.subcategory_obj: + taxonomy.append(complaint.subcategory_obj.get_localized_name()) + if complaint.classification_obj: + taxonomy.append(complaint.classification_obj.get_localized_name()) + + staff_list = [] + for inv in complaint.involved_staff.all(): + if inv.staff: + staff_list.append(inv.staff.get_localized_name()) + if not staff_list and complaint.staff: + staff_list.append(complaint.staff.get_localized_name()) + + location_parts = [] + if complaint.legacy_location: + location_parts.append(str(complaint.legacy_location)) + if complaint.legacy_main_section: + location_parts.append(str(complaint.legacy_main_section)) + if complaint.legacy_subsection: + location_parts.append(str(complaint.legacy_subsection)) + + complaint_ct = ContentType.objects.get_for_model(Complaint) + notes = complaint.notes.select_related("created_by").order_by("-created_at") + + context = { + "department": department, + "complaint": complaint, + "taxonomy": taxonomy, + "staff_list": staff_list, + "location_str": " > ".join(location_parts) if location_parts else "", + "can_respond": can_respond, + "can_manager_review": can_manager_review, + "involved_dept": involved_dept, + "notes": notes, + "content_type_id": complaint_ct.pk, + "object_id": complaint.pk, + } + return render(request, "organizations/department_complaint_detail.html", context) + + +@login_required +def department_inquiry_detail(request, pk, ipk): + from .models import Department + from apps.complaints.models import Inquiry + from django.contrib.contenttypes.models import ContentType + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + if not _check_department_access(request.user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + inquiry = get_object_or_404( + Inquiry.objects.select_related( + "patient", "hospital", "department", "assigned_to", + "outgoing_department", "legacy_location", "legacy_main_section", "legacy_subsection", + "department_responded_by", + ), + pk=ipk, + ) + + belongs = ( + (inquiry.department_id == department.pk and inquiry.sent_to_department) + or inquiry.outgoing_department_id == department.pk + ) + if not belongs: + messages.error(request, _("This inquiry does not belong to this department.")) + return redirect("organizations:department_inquiries_list", pk=pk) + + user = request.user + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or ( + user.is_champion() + and user.department + and user.department in [inquiry.department, inquiry.outgoing_department] + ) + ) + + location_parts = [] + if inquiry.legacy_location: + location_parts.append(str(inquiry.legacy_location)) + if inquiry.legacy_main_section: + location_parts.append(str(inquiry.legacy_main_section)) + if inquiry.legacy_subsection: + location_parts.append(str(inquiry.legacy_subsection)) + + inquiry_ct = ContentType.objects.get_for_model(Inquiry) + notes = inquiry.notes.select_related("created_by").order_by("-created_at") + + context = { + "department": department, + "inquiry": inquiry, + "location_str": " > ".join(location_parts) if location_parts else "", + "can_respond": can_respond, + "notes": notes, + "content_type_id": inquiry_ct.pk, + "object_id": inquiry.pk, + } + return render(request, "organizations/department_inquiry_detail.html", context) + + +@login_required +def department_observation_detail(request, pk, opk): + from .models import Department + from apps.observations.models import Observation + from django.contrib.contenttypes.models import ContentType + from django.utils import timezone + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + if not _check_department_access(request.user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + observation = get_object_or_404( + Observation.objects.select_related( + "category", "assigned_department", "assigned_to", + "legacy_location", "legacy_main_section", "legacy_subsection", "hospital", + "department_responded_by", + ), + pk=opk, + ) + + if observation.assigned_department_id != department.pk or not observation.sent_to_department: + messages.error(request, _("This observation does not belong to this department.")) + return redirect("organizations:department_observations_list", pk=pk) + + user = request.user + can_respond = ( + user.is_px_admin() + or user.is_hospital_admin() + or (user.is_department_manager() and user.department == department) + or (user.is_champion() and user.department == department) + ) + + obs_ct = ContentType.objects.get_for_model(Observation) + notes = observation.notes.select_related("created_by").order_by("-created_at") + + context = { + "department": department, + "observation": observation, + "can_respond": can_respond, + "notes": notes, + "now": timezone.now(), + "content_type_id": obs_ct.pk, + "object_id": observation.pk, + } + return render(request, "organizations/department_observation_detail.html", context) + + +@login_required +def department_staff_detail(request, pk, spk): + from .models import Department + from apps.complaints.models import Complaint, ComplaintInvolvedStaff + from django.db.models import Q + + department = get_object_or_404( + Department.objects.select_related("hospital"), pk=pk + ) + if not _check_department_access(request.user, department): + messages.error(request, _("You don't have permission to view this department.")) + return redirect("organizations:department_list") + + staff = get_object_or_404( + Staff.objects.select_related("user", "department", "section_fk", "subsection_fk", "report_to"), + pk=spk, + department=department, + status="active", + ) + + complaint_q = Q( + Q(department=department, sent_to_department=True) + | Q(involved_departments__department=department, involved_departments__sent=True) + ) + + direct_complaints = Complaint.objects.filter( + staff=staff, sent_to_department=True, + ).filter( + Q(department=department) | Q(involved_departments__department=department), + ).select_related("patient", "assigned_to", "category").distinct() + + involved_records = ComplaintInvolvedStaff.objects.filter( + staff=staff, + complaint__sent_to_department=True, + ).filter( + Q(complaint__department=department) | Q(complaint__involved_departments__department=department), + ).select_related("complaint__patient", "complaint__assigned_to", "complaint__category").distinct() + + complaint_rows = [] + seen_ids = set() + for c in direct_complaints: + complaint_rows.append({"complaint": c, "role": _("Primary")}) + seen_ids.add(c.id) + for inv in involved_records: + if inv.complaint_id not in seen_ids: + complaint_rows.append({"complaint": inv.complaint, "role": inv.get_role_display()}) + seen_ids.add(inv.complaint_id) + complaint_rows.sort(key=lambda r: r["complaint"].created_at, reverse=True) + + all_complaints = [r["complaint"] for r in complaint_rows] + complaint_counts = { + "total": len(all_complaints), + "open": sum(1 for c in all_complaints if c.status == "open"), + "in_progress": sum(1 for c in all_complaints if c.status == "in_progress"), + "resolved": sum(1 for c in all_complaints if c.status in ("resolved", "partially_resolved")), + "closed": sum(1 for c in all_complaints if c.status == "closed"), + } + + direct_reports = Staff.objects.filter( + report_to=staff, department=department, status="active", + ).order_by("first_name") + + from django.contrib.contenttypes.models import ContentType + from apps.appreciation.models import Appreciation + + recipient_ctypes = [ContentType.objects.get_for_model(Staff)] + recipient_ids = [staff.pk] + if staff.user: + recipient_ctypes.append(ContentType.objects.get_for_model(staff.user)) + recipient_ids.append(staff.user.pk) + + appreciations = Appreciation.objects.filter( + department=department, + recipient_content_type__in=recipient_ctypes, + recipient_object_id__in=recipient_ids, + ).select_related("sender", "category").order_by("-created_at") + + context = { + "department": department, + "staff": staff, + "complaint_rows": complaint_rows, + "complaint_counts": complaint_counts, + "direct_reports": direct_reports, + "appreciations": appreciations, + "can_edit_staff": ( + request.user.is_px_admin() + or (request.user.is_hospital_admin() and request.user.hospital == department.hospital) + or (request.user.is_champion() and request.user.department == department) + or (request.user.is_department_manager() and request.user.department == department) + ), + } + return render(request, "organizations/department_staff_detail.html", context) + + +@login_required +@require_http_methods(["POST"]) +def department_staff_update(request, pk, spk): + from .models import Department + + department = get_object_or_404(Department.objects.select_related("hospital"), pk=pk) + user = request.user + if not ( + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == department.hospital) + or (user.is_champion() and user.department == department) + or (user.is_department_manager() and user.department == department) + ): + messages.error(request, _("You don't have permission to update staff info.")) + return redirect("organizations:department_staff_detail", pk=pk, spk=spk) + + staff = get_object_or_404( + Staff.objects.select_related("department"), + pk=spk, + department=department, + status="active", + ) + + EDITABLE_FIELDS = [ + "first_name", "last_name", "first_name_ar", "last_name_ar", + "job_title", "job_title_ar", "email", "phone", + "specialization", "license_number", "gender", + ] + + changed = [] + for field in EDITABLE_FIELDS: + value = request.POST.get(field, "").strip() + old = getattr(staff, field) or "" + if field in ("first_name", "last_name"): + if not value: + messages.error(request, _(f"{field.replace('_', ' ').title()} is required.")) + return redirect("organizations:department_staff_detail", pk=pk, spk=spk) + setattr(staff, field, value) + if value != old: + changed.append(field) + + if changed: + staff.save(update_fields=changed) + messages.success(request, _("Staff info updated successfully.")) + else: + messages.info(request, _("No changes detected.")) + + return redirect("organizations:department_staff_detail", pk=pk, spk=spk) + + +@login_required +def department_record_complaint(request, pk): + from django.http import JsonResponse + from apps.complaints.models import Complaint + + complaint = get_object_or_404( + Complaint.objects.select_related( + "hospital", "department", "assigned_to", "category", "domain", + "subcategory_obj", "classification_obj", "legacy_location", "legacy_main_section", + "legacy_subsection", "patient", "staff", "source", + ).prefetch_related("involved_staff__staff"), + pk=pk, + ) + + department = get_object_or_404(Department, pk=pk if False else complaint.department_id) + user = request.user + if not ( + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == complaint.hospital) + or (user.is_champion() and user.department_id == complaint.department_id) + or (user.is_department_manager() and user.department_id == complaint.department_id) + or (user.is_basic_staff() and user.department_id == complaint.department_id) + or (user.is_director() and user.get_directed_departments().filter(id=complaint.department_id).exists()) + ): + return JsonResponse({"error": "Access denied"}, status=403) + + taxonomy = [] + if complaint.domain: + taxonomy.append(complaint.domain.get_localized_name()) + if complaint.category: + taxonomy.append(complaint.category.get_localized_name()) + if complaint.subcategory_obj: + taxonomy.append(complaint.subcategory_obj.get_localized_name()) + if complaint.classification_obj: + taxonomy.append(complaint.classification_obj.get_localized_name()) + + staff_list = [] + for inv in complaint.involved_staff.all(): + staff_list.append(inv.staff.get_localized_name() if inv.staff else "-") + if not staff_list and complaint.staff: + staff_list.append(complaint.staff.get_localized_name()) + + location_parts = [] + if complaint.legacy_location: + location_parts.append(str(complaint.legacy_location)) + if complaint.legacy_main_section: + location_parts.append(str(complaint.legacy_main_section)) + if complaint.legacy_subsection: + location_parts.append(str(complaint.legacy_subsection)) + + data = { + "type": "complaint", + "reference": complaint.reference_number or "", + "title": complaint.title or "", + "description": complaint.description or "", + "status": complaint.status, + "status_display": complaint.get_status_display(), + "severity": complaint.severity, + "severity_display": complaint.get_severity_display(), + "department": complaint.department.get_localized_name() if complaint.department else "-", + "assigned_to": complaint.assigned_to.get_full_name() if complaint.assigned_to else "-", + "patient": complaint.patient.get_full_name() if complaint.patient else (complaint.patient_name or "-"), + "patient_mrn": complaint.patient.mrn if complaint.patient else "-", + "staff": staff_list, + "taxonomy": taxonomy, + "location": " > ".join(location_parts) if location_parts else "-", + "source": complaint.source.name_en if complaint.source else "-", + "created_at": complaint.created_at.strftime("%Y-%m-%d %H:%M"), + "due_at": complaint.due_at.strftime("%Y-%m-%d %H:%M") if complaint.due_at else None, + "is_overdue": complaint.is_overdue, + "escalated": bool(complaint.escalated_at), + "expected_result": complaint.expected_result or "", + "ai_brief": complaint.ai_brief_en or "", + } + return JsonResponse(data) + + +@login_required +def department_record_inquiry(request, pk): + from django.http import JsonResponse + from apps.complaints.models import Inquiry + + inquiry = get_object_or_404( + Inquiry.objects.select_related( + "hospital", "department", "assigned_to", "legacy_location", "legacy_main_section", + "legacy_subsection", "outgoing_department", + ), + pk=pk, + ) + + user = request.user + dept_id = inquiry.department_id or inquiry.outgoing_department_id + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or (user.is_champion() and user.department and (user.department_id == inquiry.department_id or user.department_id == inquiry.outgoing_department_id)) + or user.is_department_manager() + or user.is_director() + ): + return JsonResponse({"error": "Access denied"}, status=403) + + location_parts = [] + if inquiry.legacy_location: + location_parts.append(str(inquiry.legacy_location)) + if inquiry.legacy_main_section: + location_parts.append(str(inquiry.legacy_main_section)) + if inquiry.legacy_subsection: + location_parts.append(str(inquiry.legacy_subsection)) + + data = { + "type": "inquiry", + "reference": inquiry.reference_number or "", + "title": inquiry.subject or "", + "description": inquiry.message or "", + "status": inquiry.status, + "status_display": inquiry.get_status_display(), + "department": inquiry.department.get_localized_name() if inquiry.department else "-", + "assigned_to": inquiry.assigned_to.get_full_name() if inquiry.assigned_to else "-", + "contact_name": inquiry.contact_name or "-", + "contact_phone": inquiry.contact_phone or "-", + "contact_email": inquiry.contact_email or "-", + "category": inquiry.get_category_display() if inquiry.category else "-", + "location": " > ".join(location_parts) if location_parts else "-", + "created_at": inquiry.created_at.strftime("%Y-%m-%d %H:%M"), + "due_at": inquiry.due_at.strftime("%Y-%m-%d %H:%M") if hasattr(inquiry, 'due_at') and inquiry.due_at else None, + "ai_summary": inquiry.short_description_en if hasattr(inquiry, 'short_description_en') else "", + } + return JsonResponse(data) + + +@login_required +def department_record_observation(request, pk): + from django.http import JsonResponse + from apps.observations.models import Observation + + observation = get_object_or_404( + Observation.objects.select_related( + "hospital", "assigned_department", "assigned_to", "category", + "legacy_location", "legacy_main_section", "legacy_subsection", + ), + pk=pk, + ) + + user = request.user + dept_id = observation.assigned_department_id + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or (user.is_champion() and user.department and user.department_id == observation.assigned_department_id) + or user.is_department_manager() + or user.is_director() + ): + return JsonResponse({"error": "Access denied"}, status=403) + + location_parts = [] + if observation.legacy_location: + location_parts.append(str(observation.legacy_location)) + if observation.legacy_main_section: + location_parts.append(str(observation.legacy_main_section)) + if observation.legacy_subsection: + location_parts.append(str(observation.legacy_subsection)) + + reporter = "" + if observation.is_anonymous: + reporter = str(_("Anonymous")) + elif observation.reporter_name: + reporter = observation.reporter_name + + data = { + "type": "observation", + "reference": observation.tracking_code or "", + "title": observation.title or "", + "description": observation.description or "", + "status": observation.status, + "status_display": observation.get_status_display(), + "severity": observation.severity, + "severity_display": observation.get_severity_display(), + "department": observation.assigned_department.get_localized_name() if observation.assigned_department else "-", + "assigned_to": observation.assigned_to.get_full_name() if observation.assigned_to else "-", + "category": observation.category.get_localized_name() if observation.category else "-", + "location": " > ".join(location_parts) if location_parts else "-", + "reporter": reporter, + "is_anonymous": observation.is_anonymous, + "created_at": observation.created_at.strftime("%Y-%m-%d %H:%M"), + "due_at": observation.due_at.strftime("%Y-%m-%d %H:%M") if observation.due_at else None, + "is_overdue": observation.is_overdue if hasattr(observation, 'is_overdue') else False, + "ai_summary": observation.short_description_en if hasattr(observation, 'short_description_en') else "", + } + return JsonResponse(data) + + +@login_required +def department_record_suggestion(request, pk): + from django.http import JsonResponse + from apps.feedback.models import Feedback + + feedback = get_object_or_404( + Feedback.objects.select_related( + "hospital", "department", "assigned_to", "staff", + "legacy_location", "legacy_main_section", "legacy_subsection", + ), + pk=pk, + ) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or (user.is_champion() and user.department and user.department_id == feedback.department_id) + or user.is_department_manager() + or user.is_director() + ): + return JsonResponse({"error": "Access denied"}, status=403) + + location_parts = [] + if feedback.legacy_location: + location_parts.append(str(feedback.legacy_location)) + if feedback.legacy_main_section: + location_parts.append(str(feedback.legacy_main_section)) + if feedback.legacy_subsection: + location_parts.append(str(feedback.legacy_subsection)) + + data = { + "type": "suggestion", + "reference": str(feedback.id)[:8].upper() if feedback.id else "", + "title": feedback.title or "", + "description": feedback.message or "", + "status": feedback.status, + "status_display": feedback.get_status_display(), + "priority": feedback.priority, + "priority_display": feedback.get_priority_display(), + "department": feedback.department.get_localized_name() if feedback.department else "-", + "assigned_to": feedback.assigned_to.get_full_name() if feedback.assigned_to else "-", + "category": feedback.get_category_display() if feedback.category else "-", + "contact_name": feedback.contact_name if not feedback.is_anonymous else str(_("Anonymous")), + "location": " > ".join(location_parts) if location_parts else "-", + "created_at": feedback.created_at.strftime("%Y-%m-%d %H:%M"), + "sentiment": feedback.get_sentiment_display() if hasattr(feedback, 'get_sentiment_display') else "", + } + return JsonResponse(data) + + +@login_required +def department_record_appreciation(request, pk): + from django.http import JsonResponse + from apps.appreciation.models import Appreciation + + appreciation = get_object_or_404( + Appreciation.objects.select_related( + "hospital", "department", "sender", "category", + "legacy_location", "legacy_main_section", "legacy_subsection", + ), + pk=pk, + ) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or (user.is_champion() and user.department and user.department_id == appreciation.department_id) + or user.is_department_manager() + or user.is_director() + ): + return JsonResponse({"error": "Access denied"}, status=403) + + location_parts = [] + if appreciation.legacy_location: + location_parts.append(str(appreciation.legacy_location)) + if appreciation.legacy_main_section: + location_parts.append(str(appreciation.legacy_main_section)) + if appreciation.legacy_subsection: + location_parts.append(str(appreciation.legacy_subsection)) + + data = { + "type": "appreciation", + "reference": str(appreciation.id)[:8].upper() if appreciation.id else "", + "title": "", + "description": appreciation.message_en or "", + "description_ar": appreciation.message_ar or "", + "status": appreciation.status, + "status_display": appreciation.get_status_display(), + "department": appreciation.department.get_localized_name() if appreciation.department else "-", + "sender": appreciation.sender.get_full_name() if appreciation.sender and not appreciation.is_anonymous else str(_("Anonymous")), + "recipient": appreciation.get_recipient_name(), + "category": appreciation.category.name_en if appreciation.category else "-", + "category_color": appreciation.category.color if appreciation.category else "", + "location": " > ".join(location_parts) if location_parts else "-", + "visibility": appreciation.get_visibility_display(), + "is_anonymous": appreciation.is_anonymous, + "created_at": appreciation.created_at.strftime("%Y-%m-%d %H:%M"), + "sent_at": appreciation.sent_at.strftime("%Y-%m-%d %H:%M") if appreciation.sent_at else None, + "acknowledged_at": appreciation.acknowledged_at.strftime("%Y-%m-%d %H:%M") if appreciation.acknowledged_at else None, + } + return JsonResponse(data) + + @login_required def department_analytics_api(request, pk): @@ -2152,12 +3150,15 @@ def department_analytics_api(request, pk): six_months_ago = now - timedelta(days=180) complaints_qs = Complaint.objects.filter( - Q(department=department) | Q(involved_departments__department=department) + Q(department=department, sent_to_department=True) | Q(involved_departments__department=department, involved_departments__sent=True) ).distinct() + complaint_ids = complaints_qs.values_list("pk", flat=True) + complaints_clean = Complaint.objects.filter(pk__in=complaint_ids) + # 1. Complaint Trend (last 12 months) complaint_trend_raw = ( - complaints_qs.filter(created_at__gte=twelve_months_ago) + complaints_clean.filter(created_at__gte=twelve_months_ago) .annotate(month=TruncMonth("created_at")) .values("month") .annotate(count=Count("pk")) @@ -2169,7 +3170,7 @@ def department_analytics_api(request, pk): ] # 2. Complaint Status Distribution - status_raw = complaints_qs.values("status").annotate(count=Count("pk")) + status_raw = complaints_clean.values("status").annotate(count=Count("pk")) status_labels = { "open": "Open", "in_progress": "In Progress", @@ -2247,7 +3248,7 @@ def department_analytics_api(request, pk): ] # 6. Satisfaction Distribution - satisfaction_raw = complaints_qs.exclude(satisfaction__isnull=True).exclude(satisfaction="").values("satisfaction").annotate(count=Count("pk")) + satisfaction_raw = complaints_clean.exclude(satisfaction__isnull=True).exclude(satisfaction="").values("satisfaction").annotate(count=Count("pk")) satisfaction_labels = { "satisfied": "Satisfied", "neutral": "Neutral", @@ -2262,7 +3263,7 @@ def department_analytics_api(request, pk): ] # 7. Complaint Severity Distribution - severity_raw = complaints_qs.values("severity").annotate(count=Count("pk")) + severity_raw = complaints_clean.values("severity").annotate(count=Count("pk")) severity_labels = { "low": "Low", "medium": "Medium", @@ -2280,7 +3281,7 @@ def department_analytics_api(request, pk): # 8. Top Categories categories_raw = ( - complaints_qs.filter(category__isnull=False) + complaints_clean.filter(category__isnull=False) .values("category__name_en") .annotate(count=Count("pk")) .order_by("-count")[:5] @@ -2291,8 +3292,8 @@ def department_analytics_api(request, pk): ] # KPIs - total_complaints = complaints_qs.count() - resolved_count = complaints_qs.filter(status__in=["resolved", "closed"]).count() + total_complaints = complaints_clean.count() + resolved_count = complaints_clean.filter(status__in=["resolved", "closed"]).count() resolution_rate = round((resolved_count / total_complaints * 100) if total_complaints else 0, 1) current_rating = physician_ratings_qs.filter(year=now.year, month=now.month).aggregate( @@ -2306,9 +3307,9 @@ def department_analytics_api(request, pk): satisfied_count = next((s["count"] for s in satisfaction if s["label"] == "Satisfied"), 0) satisfaction_rate = round((satisfied_count / total_satisfaction * 100) if total_satisfaction else 0, 1) - reopened_count = complaints_qs.filter(reopened_from__isnull=False).count() + reopened_count = complaints_clean.filter(reopened_from__isnull=False).count() reassigned_count = ComplaintUpdate.objects.filter( - complaint__in=complaints_qs, update_type="assignment" + complaint__in=complaints_clean, update_type="assignment" ).distinct().count() return JsonResponse( @@ -2394,7 +3395,11 @@ def set_department_respondent(request, pk): @login_required @require_http_methods(["POST"]) -def set_department_manager(request, pk): + + +@login_required +@require_http_methods(["POST"]) +def set_department_role(request, pk): from .models import Department, Staff department = get_object_or_404(Department, pk=pk) @@ -2402,57 +3407,72 @@ def set_department_manager(request, pk): if not ( user.is_px_admin() + or user.is_px_employee() or (user.is_hospital_admin() and user.hospital == department.hospital) ): - messages.error(request, _("You don't have permission to set the manager.")) + messages.error(request, _("You don't have permission to set department roles.")) return redirect("organizations:department_detail", pk=pk) - manager_id = request.POST.get("manager_id") - if manager_id: - manager = Staff.objects.filter( - pk=manager_id, hospital=department.hospital, status="active" + role_field = request.POST.get("role_field", "") + valid_fields = {f[0] for f in Department.ROLE_FIELDS} | {"manager"} + if role_field not in valid_fields: + messages.error(request, _("Invalid role.")) + return redirect("organizations:department_detail", pk=pk) + + staff_id = request.POST.get("staff_id") + role_labels = dict(Department.ROLE_FIELDS) + role_labels["manager"] = "Manager" + role_label = role_labels.get(role_field, role_field) + + if staff_id: + staff = Staff.objects.filter( + pk=staff_id, hospital=department.hospital, status="active" ).first() - if not manager: + if not staff: messages.error(request, _("Invalid staff member selected.")) return redirect("organizations:department_detail", pk=pk) - if not manager.user: - from apps.organizations.services import StaffService - from django.contrib.auth.models import Group + if role_field == "manager": + if not staff.user: + from apps.organizations.services import StaffService - if not manager.email: - messages.warning( - request, - _(f"Selected staff {manager.get_full_name()} has no email. Add an email and create a user account first."), - ) - return redirect("organizations:department_detail", pk=pk) - - try: - user_account, _created, _result = StaffService.create_user_for_staff( - manager, role="department_manager", request=request - ) - department.manager = user_account + if not staff.email: + messages.warning( + request, + _(f"Selected staff {staff.get_full_name()} has no email. Add an email and create a user account first."), + ) + return redirect(f"{reverse('organizations:department_detail', kwargs={'pk': pk})}?tab=roles") + try: + user_account, _created, _result = StaffService.create_user_for_staff( + staff, role="department_manager", request=request + ) + department.manager = user_account + department.save(update_fields=["manager", "updated_at"]) + messages.success( + request, + _(f"Manager set to {staff.get_full_name()}. User account created with Department Manager role."), + ) + except ValueError as e: + messages.error(request, _(f"Could not create user account: {e}")) + return redirect(f"{reverse('organizations:department_detail', kwargs={'pk': pk})}?tab=roles") + else: + department.manager = staff.user department.save(update_fields=["manager", "updated_at"]) - messages.success( - request, - _(f"Manager set to {manager.get_full_name()}. User account created with Department Manager role."), - ) - except ValueError as e: - messages.error(request, _(f"Could not create user account: {e}")) - return redirect("organizations:department_detail", pk=pk) + messages.success(request, _(f"Manager set to {staff.get_full_name()}.")) else: - department.manager = manager.user - department.save(update_fields=["manager", "updated_at"]) - messages.success( - request, - _(f"Manager set to {manager.get_full_name()}."), - ) + setattr(department, role_field, staff) + department.save(update_fields=[role_field, "updated_at"]) + messages.success(request, _(f"{role_label} set to {staff.get_full_name()}.")) else: - department.manager = None - department.save(update_fields=["manager", "updated_at"]) - messages.success(request, _("Manager removed.")) + if role_field == "manager": + department.manager = None + department.save(update_fields=["manager", "updated_at"]) + else: + setattr(department, role_field, None) + department.save(update_fields=[role_field, "updated_at"]) + messages.success(request, _(f"{role_label} removed.")) - return redirect("organizations:department_detail", pk=pk) + return redirect(f"{reverse('organizations:department_detail', kwargs={'pk': pk})}?tab=roles") @login_required @@ -2882,3 +3902,712 @@ def staff_import_sample_csv(request): writer.writerow(headers) writer.writerow(example) return response + + +@block_source_user +@login_required +def department_manager_review(request, pk, idept_pk): + """ + Department Manager reviews a champion's response. + Shows the champion response + configurable questions form. + On approval, notifies PX Admin. On rejection, notifies champion. + """ + from apps.complaints.models import ( + ComplaintInvolvedDepartment, + ManagerReviewQuestion, + DepartmentManagerReview, + ManagerReviewAnswer, + ComplaintUpdate, + ) + from apps.core.services import AuditService + + department = get_object_or_404(Department.objects.select_related("hospital"), pk=pk) + involved_dept = get_object_or_404( + ComplaintInvolvedDepartment.objects.select_related( + "complaint", "complaint__assigned_to", "department", + ), + pk=idept_pk, + department=department, + ) + user = request.user + + if not user.is_px_admin() and not user.is_hospital_admin(): + if not ( + user.is_department_manager() + and (user.department == department or department.manager == user) + ): + messages.error(request, _("Only the department manager can review champion responses.")) + return redirect("organizations:department_detail", pk=department.pk) + + if not involved_dept.response_submitted: + messages.error(request, _("No champion response submitted yet.")) + return redirect("organizations:department_detail", pk=department.pk) + + if involved_dept.manager_review_status == "approved": + messages.info(request, _("This response has already been approved.")) + return redirect("organizations:department_detail", pk=department.pk) + + complaint = involved_dept.complaint + questions = ManagerReviewQuestion.objects.filter( + hospital=department.hospital, is_active=True, + ).order_by("order", "created_at") + + if request.method == "POST": + action = request.POST.get("review_action") + if action not in ("approve", "reject"): + messages.error(request, _("Invalid action.")) + return redirect("organizations:department_manager_review", pk=department.pk, idept_pk=involved_dept.pk) + + rejection_reason = request.POST.get("rejection_reason", "").strip() + + if action == "reject" and not rejection_reason: + messages.error(request, _("Please provide a rejection reason.")) + return redirect("organizations:department_manager_review", pk=department.pk, idept_pk=involved_dept.pk) + + review_obj = DepartmentManagerReview.objects.create( + involved_department=involved_dept, + reviewed_by=user, + status="approved" if action == "approve" else "rejected", + rejection_reason=rejection_reason, + ) + + for q in questions: + answer_key = f"question_{q.pk}" + answer = ManagerReviewAnswer( + review=review_obj, + question=q, + ) + if q.question_type in ("text", "textarea"): + answer.text_value = request.POST.get(answer_key, "").strip() + elif q.question_type == "yes_no": + val = request.POST.get(answer_key, "") + answer.text_value = val + elif q.question_type == "rating": + try: + answer.numeric_value = int(request.POST.get(answer_key, 0)) + except (ValueError, TypeError): + answer.numeric_value = None + elif q.question_type == "multiple_choice": + answer.text_value = request.POST.get(answer_key, "").strip() + answer.save() + + if action == "approve": + involved_dept.manager_review_status = "approved" + involved_dept.manager_reviewed_by = user + involved_dept.manager_reviewed_at = timezone.now() + involved_dept.save() + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Department manager {user.get_full_name()} approved the response from {department.name}.", + created_by=user, + ) + + AuditService.log_event( + event_type="dept_manager_approved_response", + description=f"Manager {user.get_full_name()} approved response for complaint {complaint.reference_number}", + user=user, + content_object=complaint, + ) + + if complaint.assigned_to and complaint.assigned_to.email: + try: + from apps.notifications.services import NotificationService, get_email_header_html + complaint_url = request.build_absolute_uri( + reverse("complaints:complaint_detail", kwargs={"pk": complaint.pk}) + ) + NotificationService.send_email( + complaint.assigned_to.email, + subject=f"Manager-Approved Response Ready - {complaint.reference_number}", + message=( + f"The department manager has approved the response from {department.name} " + f"for complaint {complaint.reference_number}. Please review.\n\n{complaint_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Manager-Approved Response Ready

+

The department manager of {department.name} has approved the champion response + for complaint {complaint.reference_number}.

+

It is now ready for your final review.

+

+ View Complaint +

+
+
+ """, + related_object=complaint, + ) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send px-admin notification: {e}") + + messages.success(request, _("Response approved and forwarded to the PX team.")) + + elif action == "reject": + involved_dept.manager_review_status = "rejected" + involved_dept.manager_reviewed_by = user + involved_dept.manager_reviewed_at = timezone.now() + involved_dept.response_submitted = False + involved_dept.response_submitted_at = None + involved_dept.response_notes = "" + involved_dept.response_notes_en = "" + involved_dept.response_notes_ar = "" + involved_dept.save() + + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="note", + message=f"Department manager {user.get_full_name()} rejected the response from {department.name}. Reason: {rejection_reason}", + created_by=user, + ) + + AuditService.log_event( + event_type="dept_manager_rejected_response", + description=f"Manager {user.get_full_name()} rejected response for complaint {complaint.reference_number}", + user=user, + content_object=complaint, + ) + + recipients = set() + if department.champion and department.champion.user and department.champion.user.email: + recipients.add(department.champion.user.email) + if involved_dept.assigned_to and involved_dept.assigned_to.email: + recipients.add(involved_dept.assigned_to.email) + for email in recipients: + try: + from apps.notifications.services import NotificationService, get_email_header_html + dept_url = request.build_absolute_uri( + reverse("organizations:department_detail", kwargs={"pk": department.pk}) + ) + NotificationService.send_email( + email, + subject=f"Response Rejected by Manager - {complaint.reference_number}", + message=( + f"The department manager rejected your response for complaint " + f"{complaint.reference_number}.\n\nReason: {rejection_reason}\n\n" + f"Please submit a new response.\n\n{dept_url}" + ), + html_message=f""" +
+ {get_email_header_html()} +
+

Response Rejected by Manager

+

The department manager has rejected your response for complaint + {complaint.reference_number}.

+

Reason: {rejection_reason}

+

Please review and submit a new response.

+

+ Go to Department +

+
+
+ """, + related_object=complaint, + ) + except Exception as e: + import logging + logging.getLogger(__name__).error(f"Failed to send rejection notification: {e}") + + messages.success(request, _("Response rejected. The champion has been notified to submit a new response.")) + + return redirect("organizations:department_detail", pk=department.pk) + + context = { + "department": department, + "involved_dept": involved_dept, + "complaint": complaint, + "questions": questions, + } + return render(request, "organizations/department_manager_review.html", context) + + +@block_source_user +@login_required +def manager_review_questions(request): + """PX Admin manages the questions shown to department managers during review.""" + from apps.complaints.models import ManagerReviewQuestion + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to manage review questions.")) + return redirect("dashboard:dashboard") + + hospital = request.tenant_hospital + if not hospital: + messages.error(request, _("No hospital context found.")) + return redirect("dashboard:dashboard") + + questions = ManagerReviewQuestion.objects.filter(hospital=hospital).order_by("order", "created_at") + + context = { + "questions": questions, + "hospital": hospital, + } + return render(request, "organizations/manager_review_questions.html", context) + + +@block_source_user +@login_required +@require_http_methods(["GET", "POST"]) +def manager_review_question_create(request): + from apps.complaints.models import ManagerReviewQuestion, ManagerReviewQuestionType + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to manage review questions.")) + return redirect("dashboard:dashboard") + + hospital = request.tenant_hospital + if not hospital: + messages.error(request, _("No hospital context found.")) + return redirect("dashboard:dashboard") + + if request.method == "POST": + text_en = request.POST.get("text_en", "").strip() + text_ar = request.POST.get("text_ar", "").strip() + question_type = request.POST.get("question_type", "textarea") + order = int(request.POST.get("order", 0)) + is_active = request.POST.get("is_active") == "on" + + if not text_en: + messages.error(request, _("English text is required.")) + return redirect("organizations:manager_review_question_create") + + choices_raw = request.POST.get("choices_json", "").strip() + choices_json = [] + if question_type == "multiple_choice" and choices_raw: + import json + try: + choices_json = json.loads(choices_raw) + except json.JSONDecodeError: + choices_json = [c.strip() for c in choices_raw.split(",") if c.strip()] + + ManagerReviewQuestion.objects.create( + hospital=hospital, + text_en=text_en, + text_ar=text_ar, + question_type=question_type, + choices_json=choices_json, + order=order, + is_active=is_active, + created_by=user, + ) + messages.success(request, _("Question created successfully.")) + return redirect("organizations:manager_review_questions") + + question_types = ManagerReviewQuestionType.choices + context = {"hospital": hospital, "question_types": question_types} + return render(request, "organizations/manager_review_question_form.html", context) + + +@block_source_user +@login_required +@require_http_methods(["GET", "POST"]) +def manager_review_question_update(request, pk): + from apps.complaints.models import ManagerReviewQuestion, ManagerReviewQuestionType + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to manage review questions.")) + return redirect("dashboard:dashboard") + + hospital = request.tenant_hospital + question = get_object_or_404(ManagerReviewQuestion, pk=pk, hospital=hospital) + + if request.method == "POST": + question.text_en = request.POST.get("text_en", "").strip() + question.text_ar = request.POST.get("text_ar", "").strip() + question.question_type = request.POST.get("question_type", "textarea") + question.order = int(request.POST.get("order", 0)) + question.is_active = request.POST.get("is_active") == "on" + + choices_raw = request.POST.get("choices_json", "").strip() + if question.question_type == "multiple_choice" and choices_raw: + import json + try: + question.choices_json = json.loads(choices_raw) + except json.JSONDecodeError: + question.choices_json = [c.strip() for c in choices_raw.split(",") if c.strip()] + else: + question.choices_json = [] + + question.save() + messages.success(request, _("Question updated successfully.")) + return redirect("organizations:manager_review_questions") + + question_types = ManagerReviewQuestionType.choices + context = {"question": question, "hospital": hospital, "question_types": question_types} + return render(request, "organizations/manager_review_question_form.html", context) + + +@block_source_user +@login_required +@require_POST +def manager_review_question_delete(request, pk): + from apps.complaints.models import ManagerReviewQuestion + + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, _("You don't have permission to manage review questions.")) + return redirect("dashboard:dashboard") + + hospital = request.tenant_hospital + question = get_object_or_404(ManagerReviewQuestion, pk=pk, hospital=hospital) + question.delete() + messages.success(request, _("Question deleted.")) + return redirect("organizations:manager_review_questions") + + +# ==================== OrgSubSection CRUD ==================== + + +@block_source_user +@login_required +def orgsection_list(request): + queryset = Section.objects.select_related("department", "department__hospital", "champion") + + user = request.user + if not user.is_px_admin() and user.hospital: + queryset = queryset.filter(department__hospital=user.hospital) + + department_filter = request.GET.get("department") + if department_filter: + queryset = queryset.filter(department_id=department_filter) + + status_filter = request.GET.get("status") + if status_filter: + queryset = queryset.filter(status=status_filter) + + search_query = request.GET.get("search") + if search_query: + queryset = queryset.filter( + Q(name_en__icontains=search_query) | Q(name_ar__icontains=search_query) | Q(code__icontains=search_query) + ) + + queryset = queryset.order_by("department__name", "name_en") + + page_size = int(request.GET.get("page_size", 25)) + paginator = Paginator(queryset, page_size) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + + departments = Department.objects.filter(status="active") + if not user.is_px_admin() and user.hospital: + departments = departments.filter(hospital=user.hospital) + + context = { + "page_obj": page_obj, + "sections": page_obj.object_list, + "departments": departments, + "filters": request.GET, + } + return render(request, "organizations/orgsection_list.html", context) + + +@block_source_user +@login_required +def orgsection_create(request): + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to create sections") + + if request.method == "POST": + name_en = request.POST.get("name_en") + name_ar = request.POST.get("name_ar", "") + code = request.POST.get("code", "") + department_id = request.POST.get("department") + status = request.POST.get("status", "active") + location_type = request.POST.get("location_type", "") + sub_location = request.POST.get("sub_location", "") + floor = request.POST.get("floor", "") + champion_id = request.POST.get("champion") + + if name_en and department_id: + department = get_object_or_404(Department, pk=department_id) + if not user.is_px_admin() and department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only create sections in your hospital") + + Section.objects.create( + name_en=name_en, + name_ar=name_ar, + code=code, + department=department, + status=status, + location_type=location_type, + sub_location=sub_location, + floor=floor, + champion_id=champion_id if champion_id else None, + ) + messages.success(request, "Section created successfully.") + return redirect("organizations:orgsection_list") + + departments = Department.objects.filter(status="active") + if not user.is_px_admin() and user.hospital: + departments = departments.filter(hospital=user.hospital) + + staff = Staff.objects.filter(status="active").order_by("first_name", "last_name") + + context = {"departments": departments, "staff": staff} + return render(request, "organizations/orgsection_form.html", context) + + +@block_source_user +@login_required +def orgsection_detail(request, pk): + section = get_object_or_404( + Section.objects.select_related("department", "department__hospital", "champion"), + pk=pk, + ) + + user = request.user + if not user.is_px_admin() and user.hospital: + if section.department.hospital != user.hospital: + messages.error(request, _("You don't have permission to view this section.")) + return redirect("organizations:orgsection_list") + + sub_sections = SubSection.objects.filter(section=section).order_by("name_en") + + context = { + "section": section, + "sub_sections": sub_sections, + "sub_sections_count": sub_sections.count(), + "can_edit": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == section.department.hospital), + } + return render(request, "organizations/orgsection_detail.html", context) + + +@block_source_user +@login_required +def orgsection_update(request, pk): + section = get_object_or_404(Section, pk=pk) + + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to update sections") + + if not user.is_px_admin() and section.department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only update sections in your hospital") + + if request.method == "POST": + section.name_en = request.POST.get("name_en", section.name_en) + section.name_ar = request.POST.get("name_ar", "") + section.code = request.POST.get("code", "") + section.status = request.POST.get("status", section.status) + section.location_type = request.POST.get("location_type", "") + section.sub_location = request.POST.get("sub_location", "") + section.floor = request.POST.get("floor", "") + champion_id = request.POST.get("champion") + section.champion_id = champion_id if champion_id else None + section.save() + + messages.success(request, "Section updated successfully.") + return redirect("organizations:orgsection_detail", pk=section.pk) + + departments = Department.objects.filter(status="active") + if not user.is_px_admin() and user.hospital: + departments = departments.filter(hospital=user.hospital) + + staff = Staff.objects.filter(status="active").order_by("first_name", "last_name") + + context = {"section": section, "departments": departments, "staff": staff} + return render(request, "organizations/orgsection_form.html", context) + + +@block_source_user +@login_required +def orgsection_delete(request, pk): + section = get_object_or_404(Section, pk=pk) + + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to delete sections") + + if not user.is_px_admin() and section.department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only delete sections in your hospital") + + if request.method == "POST": + sub_count = section.sub_subsections.count() + if sub_count > 0: + messages.error(request, f"Cannot delete section. {sub_count} sub-sections are linked to it.") + return redirect("organizations:orgsection_list") + + section.delete() + messages.success(request, "Section deleted successfully.") + return redirect("organizations:orgsection_list") + + context = {"section": section} + return render(request, "organizations/orgsection_confirm_delete.html", context) + + +# ==================== SubSection CRUD ==================== + + +@block_source_user +@login_required +def orgsubsection_list(request): + queryset = SubSection.objects.select_related("section", "section__department", "section__department__hospital") + + user = request.user + if not user.is_px_admin() and user.hospital: + queryset = queryset.filter(section__department__hospital=user.hospital) + + section_filter = request.GET.get("section") + if section_filter: + queryset = queryset.filter(section_id=section_filter) + + status_filter = request.GET.get("status") + if status_filter: + queryset = queryset.filter(status=status_filter) + + search_query = request.GET.get("search") + if search_query: + queryset = queryset.filter( + Q(name_en__icontains=search_query) | Q(name_ar__icontains=search_query) | Q(code__icontains=search_query) + ) + + queryset = queryset.order_by("section__name_en", "name_en") + + page_size = int(request.GET.get("page_size", 25)) + paginator = Paginator(queryset, page_size) + page_number = request.GET.get("page", 1) + page_obj = paginator.get_page(page_number) + + org_sections = Section.objects.filter(status="active").select_related("department") + if not user.is_px_admin() and user.hospital: + org_sections = org_sections.filter(department__hospital=user.hospital) + + context = { + "page_obj": page_obj, + "sub_sections": page_obj.object_list, + "org_sections": org_sections, + "filters": request.GET, + } + return render(request, "organizations/orgsubsection_list.html", context) + + +@block_source_user +@login_required +def orgsubsection_create(request): + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to create sub-sections") + + preselected_section_id = request.GET.get("section") + preselected_section = None + if preselected_section_id: + try: + preselected_section = Section.objects.get(pk=preselected_section_id) + except (Section.DoesNotExist, ValueError): + preselected_section = None + + if request.method == "POST": + name_en = request.POST.get("name_en") + name_ar = request.POST.get("name_ar", "") + code = request.POST.get("code", "") + section_id = request.POST.get("section") + status = request.POST.get("status", "active") + champion_id = request.POST.get("champion") + + if name_en and section_id: + parent_section = get_object_or_404(Section, pk=section_id) + if not user.is_px_admin() and parent_section.department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only create sub-sections in your hospital") + + SubSection.objects.create( + name_en=name_en, + name_ar=name_ar, + code=code, + section=parent_section, + status=status, + champion_id=champion_id if champion_id else None, + ) + messages.success(request, "Sub-section created successfully.") + return redirect("organizations:orgsection_detail", pk=section_id) + + org_sections = Section.objects.filter(status="active").select_related("department") + if not user.is_px_admin() and user.hospital: + org_sections = org_sections.filter(department__hospital=user.hospital) + + staff = Staff.objects.filter(status="active").order_by("first_name", "last_name") + + context = { + "org_sections": org_sections, + "preselected_section": preselected_section, + "staff": staff, + } + return render(request, "organizations/orgsubsection_form.html", context) + + +@block_source_user +@login_required +def orgsubsection_update(request, pk): + sub_section = get_object_or_404(SubSection, pk=pk) + + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to update sub-sections") + + if not user.is_px_admin() and sub_section.section.department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only update sub-sections in your hospital") + + if request.method == "POST": + sub_section.name_en = request.POST.get("name_en", sub_section.name_en) + sub_section.name_ar = request.POST.get("name_ar", "") + sub_section.code = request.POST.get("code", "") + sub_section.status = request.POST.get("status", sub_section.status) + champion_id = request.POST.get("champion") + sub_section.champion_id = champion_id if champion_id else None + sub_section.save() + + messages.success(request, "Sub-section updated successfully.") + return redirect("organizations:orgsection_detail", pk=sub_section.section_id) + + org_sections = Section.objects.filter(status="active").select_related("department") + if not user.is_px_admin() and user.hospital: + org_sections = org_sections.filter(department__hospital=user.hospital) + + staff = Staff.objects.filter(status="active").order_by("first_name", "last_name") + + context = { + "sub_section": sub_section, + "org_sections": org_sections, + "staff": staff, + } + return render(request, "organizations/orgsubsection_form.html", context) + + +@block_source_user +@login_required +def orgsubsection_delete(request, pk): + sub_section = get_object_or_404(SubSection, pk=pk) + + user = request.user + if not user.is_px_admin() and not user.is_hospital_admin(): + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You don't have permission to delete sub-sections") + + if not user.is_px_admin() and sub_section.section.department.hospital != user.hospital: + from django.http import HttpResponseForbidden + return HttpResponseForbidden("You can only delete sub-sections in your hospital") + + if request.method == "POST": + parent_pk = sub_section.section_id + sub_section.delete() + messages.success(request, "Sub-section deleted successfully.") + return redirect("organizations:orgsection_detail", pk=parent_pk) + + context = {"sub_section": sub_section} + return render(request, "organizations/orgsubsection_confirm_delete.html", context) diff --git a/apps/organizations/urls.py b/apps/organizations/urls.py index 979433f..05df917 100644 --- a/apps/organizations/urls.py +++ b/apps/organizations/urls.py @@ -4,20 +4,24 @@ from rest_framework.routers import DefaultRouter from .views import ( DepartmentViewSet, HospitalViewSet, - LocationViewSet, - MainSectionViewSet, + LegacyLocationViewSet, + LegacyMainSectionViewSet, + LegacySubSectionViewSet, + OrgSubSectionViewSet, OrganizationViewSet, PatientViewSet, StaffViewSet, - SubSectionViewSet, - api_location_list, + api_areas_by_hospital, + api_department_contacts, + api_departments_by_category, api_main_section_list, + api_sections_by_department, + api_subsections_by_section, api_staff_hierarchy, api_staff_hierarchy_children, + api_staff_by_department, api_subsection_list, ajax_departments, - ajax_main_sections, - ajax_subsections, ) from . import ui_views from .ui_views import ( @@ -32,6 +36,15 @@ from .ui_views import ( subsection_create, subsection_update, subsection_delete, + orgsection_list, + orgsection_create, + orgsection_detail, + orgsection_update, + orgsection_delete, + orgsubsection_list, + orgsubsection_create, + orgsubsection_update, + orgsubsection_delete, ) app_name = "organizations" @@ -42,9 +55,10 @@ router.register(r"hospitals", HospitalViewSet, basename="hospital-api") router.register(r"departments", DepartmentViewSet, basename="department-api") router.register(r"staff", StaffViewSet, basename="staff-api") router.register(r"patients", PatientViewSet, basename="patient-api") -router.register(r"locations", LocationViewSet, basename="location-api") -router.register(r"main-sections", MainSectionViewSet, basename="main-section-api") -router.register(r"subsections", SubSectionViewSet, basename="subsection-api") +router.register(r"locations", LegacyLocationViewSet, basename="location-api") +router.register(r"main-sections", LegacyMainSectionViewSet, basename="main-section-api") +router.register(r"subsections", LegacySubSectionViewSet, basename="subsection-api") +router.register(r"org-sections", OrgSubSectionViewSet, basename="org-section-api") urlpatterns = [ # UI Views (come first - more specific routes) @@ -55,8 +69,25 @@ urlpatterns = [ path("departments/", ui_views.department_list, name="department_list"), path("departments//", ui_views.department_detail, name="department_detail"), path("departments//analytics/", ui_views.department_analytics_api, name="department_analytics_api"), - path("departments//set-respondent/", ui_views.set_department_respondent, name="set_department_respondent"), - path("departments//set-manager/", ui_views.set_department_manager, name="set_department_manager"), + path("departments//complaints/", ui_views.department_complaints_list, name="department_complaints_list"), + path("departments//complaints//", ui_views.department_complaint_detail, name="department_complaint_detail"), + path("departments//manager-review//", ui_views.department_manager_review, name="department_manager_review"), + path("manager-review-questions/", ui_views.manager_review_questions, name="manager_review_questions"), + path("manager-review-questions/create/", ui_views.manager_review_question_create, name="manager_review_question_create"), + path("manager-review-questions//edit/", ui_views.manager_review_question_update, name="manager_review_question_update"), + path("manager-review-questions//delete/", ui_views.manager_review_question_delete, name="manager_review_question_delete"), + path("departments//inquiries/", ui_views.department_inquiries_list, name="department_inquiries_list"), + path("departments//inquiries//", ui_views.department_inquiry_detail, name="department_inquiry_detail"), + path("departments//observations/", ui_views.department_observations_list, name="department_observations_list"), + path("departments//observations//", ui_views.department_observation_detail, name="department_observation_detail"), + path("departments//staff//edit/", ui_views.department_staff_update, name="department_staff_update"), + path("departments//staff//", ui_views.department_staff_detail, name="department_staff_detail"), + path("api/dept-complaint//", ui_views.department_record_complaint, name="department_record_complaint"), + path("api/dept-inquiry//", ui_views.department_record_inquiry, name="department_record_inquiry"), + path("api/dept-observation//", ui_views.department_record_observation, name="department_record_observation"), + path("api/dept-suggestion//", ui_views.department_record_suggestion, name="department_record_suggestion"), + path("api/dept-appreciation//", ui_views.department_record_appreciation, name="department_record_appreciation"), + path("departments//set-role/", ui_views.set_department_role, name="set_department_role"), path("staff/create/", ui_views.staff_create, name="staff_create"), path("staff/import/", ui_views.staff_import, name="staff_import"), path("staff/import/sample-csv/", ui_views.staff_import_sample_csv, name="staff_import_sample_csv"), @@ -93,14 +124,29 @@ urlpatterns = [ path("subsections/create/", subsection_create, name="subsection_create"), path("subsections//edit/", subsection_update, name="subsection_update"), path("subsections//delete/", subsection_delete, name="subsection_delete"), + # Org Section CRUD + path("org-sections/", orgsection_list, name="orgsection_list"), + path("org-sections/create/", orgsection_create, name="orgsection_create"), + path("org-sections//", orgsection_detail, name="orgsection_detail"), + path("org-sections//edit/", orgsection_update, name="orgsection_update"), + path("org-sections//delete/", orgsection_delete, name="orgsection_delete"), + # Org Sub-Section CRUD + path("org-sub-sections/", orgsubsection_list, name="orgsubsection_list"), + path("org-sub-sections/create/", orgsubsection_create, name="orgsubsection_create"), + path("org-sub-sections//edit/", orgsubsection_update, name="orgsubsection_update"), + path("org-sub-sections//delete/", orgsubsection_delete, name="orgsubsection_delete"), # API Routes for complaint form dropdowns (public access) - path("dropdowns/locations/", api_location_list, name="api_location_list"), path("dropdowns/main-sections/", api_main_section_list, name="api_main_section_list"), path("dropdowns/subsections/", api_subsection_list, name="api_subsection_list"), # AJAX Routes for cascading dropdowns in complaint form - path("ajax/main-sections/", ajax_main_sections, name="ajax_main_sections"), - path("ajax/subsections/", ajax_subsections, name="ajax_subsections"), path("ajax/departments/", ajax_departments, name="ajax_departments"), + # New hierarchy API endpoints + path("dropdowns/departments-by-category/", api_departments_by_category, name="api_departments_by_category"), + path("dropdowns/sections//", api_sections_by_department, name="api_sections_by_department"), + path("dropdowns/subsections//", api_subsections_by_section, name="api_subsections_by_section"), + path("dropdowns/areas/", api_areas_by_hospital, name="api_areas_by_hospital"), + path("dropdowns/staff-by-department//", api_staff_by_department, name="api_staff_by_department"), + path("dropdowns/department-contacts//", api_department_contacts, name="api_department_contacts"), # Staff Hierarchy API (for D3 visualization) path("api/staff/hierarchy/", api_staff_hierarchy, name="api_staff_hierarchy"), path( diff --git a/apps/organizations/views.py b/apps/organizations/views.py index 5116b0a..04e9db6 100644 --- a/apps/organizations/views.py +++ b/apps/organizations/views.py @@ -3,6 +3,7 @@ Organizations views and viewsets """ from django.db import models +from django.http import JsonResponse from rest_framework import status, viewsets from rest_framework.decorators import action, api_view, permission_classes from rest_framework.permissions import IsAuthenticated, AllowAny @@ -15,7 +16,19 @@ from apps.accounts.permissions import ( IsPXAdmin, ) -from .models import Department, Hospital, Organization, Patient, Staff, Location, MainSection, SubSection +from .models import ( + Area, + Department, + Hospital, + Organization, + Patient, + Staff, + LegacyLocation, + LegacyMainSection, + LegacySubSection, + OrgSubSection, + Section, +) from .models import Staff as StaffModel from .serializers import ( DepartmentSerializer, @@ -23,6 +36,7 @@ from .serializers import ( LocationSerializer, MainSectionSerializer, OrganizationSerializer, + OrgSubSectionSerializer, PatientListSerializer, PatientSerializer, StaffSerializer, @@ -277,19 +291,22 @@ class StaffViewSet(viewsets.ModelViewSet): {"error": "You can only create accounts for staff in your department"}, status=status.HTTP_403_FORBIDDEN ) - # Get role - always 'staff' by default for staff accounts - from .services import StaffService + # Get role from request or default to 'staff' + role = request.data.get('role', 'staff') if hasattr(request, 'data') else 'staff' + allowed_roles = ['staff', 'px_employee', 'hospital_admin', 'department_manager'] + if role not in allowed_roles: + role = 'staff' - role = 'staff' + from .services import StaffService try: user_account, was_created, password = StaffService.create_user_for_staff(staff, role=role, request=request) if was_created: - # Send email with credentials (password is already set in create_user_for_staff) + # Send password reset link try: - StaffService.send_credentials_email(staff, password, request) - message = "User account created and credentials emailed successfully" + StaffService.send_password_reset_email(staff, request) + message = "User account created and password reset link emailed successfully" except Exception as e: message = f"User account created. Email sending failed: {str(e)}" else: @@ -378,8 +395,7 @@ class StaffViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"]) def send_invitation(self, request, pk=None): """ - Send credentials email to staff member. - Generates new password and emails it. + Send a password reset email to staff member. """ staff = self.get_object() @@ -402,18 +418,10 @@ class StaffViewSet(viewsets.ModelViewSet): from .services import StaffService try: - # Generate new password - password = StaffService.generate_password() - - # Update user password - staff.user.set_password(password) - staff.user.save() - - # Send email - StaffService.send_credentials_email(staff, password, request) + StaffService.send_password_reset_email(staff, request) serializer = self.get_serializer(staff) - return Response({"message": "Invitation email sent successfully", "staff": serializer.data}) + return Response({"message": "Password reset email sent successfully", "staff": serializer.data}) except ValueError as e: return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) @@ -421,14 +429,9 @@ class StaffViewSet(viewsets.ModelViewSet): @action(detail=True, methods=["post"]) def reset_password_and_resend(self, request, pk=None): """ - Reset user password and resend credentials email. + Reset user password and resend password reset link. - This action: - 1. Generates a new random password - 2. Updates the user's password - 3. Sends credentials email via NotificationService - - Returns the new password in the response (only shown to admin). + This action sends a secure one-time password reset link to the staff member. """ staff = self.get_object() @@ -452,14 +455,13 @@ class StaffViewSet(viewsets.ModelViewSet): try: # Reset password and resend credentials - new_password, notification_log = StaffService.reset_password_and_resend_credentials(staff, request=request) + _, notification_log = StaffService.reset_password_and_resend_credentials(staff, request=request) serializer = self.get_serializer(staff) return Response( { "success": True, - "message": "Password reset successfully and credentials email sent", - "new_password": new_password, # Show password to admin + "message": "Password reset link sent successfully", "email_sent_to": staff.email, "notification_log_id": str(notification_log.id) if notification_log else None, "staff": serializer.data, @@ -679,54 +681,65 @@ class PatientViewSet(viewsets.ModelViewSet): return Response(data) -class LocationViewSet(viewsets.ReadOnlyModelViewSet): +class LegacyLocationViewSet(viewsets.ReadOnlyModelViewSet): """ - ViewSet for Location model. + ViewSet for LegacyLocation model. Publicly accessible for complaint form dropdowns. """ - queryset = Location.objects.all() + queryset = LegacyLocation.objects.all() serializer_class = LocationSerializer permission_classes = [] # Public access ordering = ["id"] -class MainSectionViewSet(viewsets.ReadOnlyModelViewSet): +class LegacyMainSectionViewSet(viewsets.ReadOnlyModelViewSet): """ - ViewSet for MainSection model. + ViewSet for LegacyMainSection model. Publicly accessible for complaint form dropdowns. """ - queryset = MainSection.objects.all() + queryset = LegacyMainSection.objects.all() serializer_class = MainSectionSerializer permission_classes = [] # Public access ordering = ["id"] -class SubSectionViewSet(viewsets.ReadOnlyModelViewSet): +class LegacySubSectionViewSet(viewsets.ReadOnlyModelViewSet): """ - ViewSet for SubSection model. + ViewSet for LegacySubSection model. Publicly accessible for complaint form dropdowns. Supports filtering by location and main_section. """ - queryset = SubSection.objects.all() + queryset = LegacySubSection.objects.all() serializer_class = SubSectionSerializer permission_classes = [] # Public access filterset_fields = ["location", "main_section"] ordering = ["internal_id"] +class OrgSubSectionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Section.objects.filter(status="active").select_related("department") + serializer_class = OrgSubSectionSerializer + filterset_fields = ["department"] + search_fields = ["name_en", "name_ar", "code"] + + +# Backward compatibility alias +SectionViewSet = OrgSubSectionViewSet + + # Dedicated API endpoints for complaint form dropdowns # These avoid conflicts with UI routes @api_view(["GET"]) @permission_classes([]) def api_location_list(request): """API endpoint for location dropdown (public access)""" - locations = Location.active_locations() + locations = LegacyLocation.active_locations() serializer = LocationSerializer(locations, many=True) return Response(serializer.data) @@ -735,7 +748,7 @@ def api_location_list(request): @permission_classes([]) def api_main_section_list(request): """API endpoint for main section dropdown (public access)""" - sections = MainSection.objects.all().order_by("id") + sections = LegacyMainSection.objects.all().order_by("id") serializer = MainSectionSerializer(sections, many=True) return Response(serializer.data) @@ -748,7 +761,7 @@ def api_subsection_list(request): - location: Filter by location ID - main_section: Filter by main section ID """ - subsections = SubSection.objects.all().order_by("name") + subsections = LegacySubSection.objects.all().order_by("name") location_id = request.GET.get("location") main_section_id = request.GET.get("main_section") @@ -775,12 +788,12 @@ def ajax_main_sections(request): if location_id: # Get main sections that have subsections for this location available_section_ids = ( - 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() ) - main_sections = MainSection.objects.filter(id__in=available_section_ids).order_by("name_en") + main_sections = LegacyMainSection.objects.filter(id__in=available_section_ids).order_by("name_en") else: - main_sections = MainSection.objects.none() + main_sections = LegacyMainSection.objects.none() serializer = MainSectionSerializer(main_sections, many=True) return Response({"sections": serializer.data}) @@ -797,16 +810,101 @@ def ajax_subsections(request): main_section_id = request.GET.get("main_section_id") if location_id and main_section_id: - subsections = SubSection.objects.filter(location_id=location_id, main_section_id=main_section_id).order_by( + subsections = LegacySubSection.objects.filter(location_id=location_id, main_section_id=main_section_id).order_by( "name_en" ) else: - subsections = SubSection.objects.none() + subsections = LegacySubSection.objects.none() serializer = SubSectionSerializer(subsections, many=True) return Response({"subsections": serializer.data}) +def api_departments_by_category(request): + from apps.organizations.serializers import DepartmentSerializer + category = request.GET.get("category", "") + hospital_id = request.GET.get("hospital", "") + qs = Department.objects.filter(status="active") + if category: + qs = qs.filter(category=category) + if hospital_id: + qs = qs.filter(hospital_id=hospital_id) + data = DepartmentSerializer(qs.order_by("name_en"), many=True).data + return JsonResponse(data, safe=False) + + +def api_sections_by_department(request, department_id): + from apps.organizations.serializers import OrgSubSectionSerializer + qs = Section.objects.filter(department_id=department_id, status="active") + data = OrgSubSectionSerializer(qs.order_by("name_en"), many=True).data + return JsonResponse(data, safe=False) + + +def api_subsections_by_section(request, section_id): + from apps.organizations.serializers import SubSectionSerializer + qs = SubSection.objects.filter(section_id=section_id, status="active") + data = SubSectionSerializer(qs.order_by("name_en"), many=True).data + return JsonResponse(data, safe=False) + + +def api_areas_by_hospital(request): + from apps.organizations.serializers import AreaSerializer + + qs = Area.objects.filter(status="active") + hospital_id = request.GET.get("hospital") + if hospital_id: + qs = qs.filter(hospital_id=hospital_id) + location_type = request.GET.get("location_type") + if location_type: + qs = qs.filter(location_type=location_type) + data = AreaSerializer(qs.order_by("name_en"), many=True).data + return JsonResponse(data, safe=False) + + +def api_staff_by_department(request, department_id): + qs = Staff.objects.filter(department_id=department_id, status="active") + data = [ + { + "id": str(s.id), + "name": s.get_full_name(), + "employee_id": s.employee_id or "", + "title": s.job_title or "", + } + for s in qs.order_by("first_name", "last_name") + ] + return JsonResponse(data, safe=False) + + + +@api_view(["GET"]) +@permission_classes([]) +def api_department_contacts(request, department_id): + dept = Department.objects.filter(pk=department_id, status="active").first() + if not dept: + return JsonResponse({"error": "Department not found"}, status=404) + holders = dept.get_role_holders() + # Convert non-serializable Staff objects to dicts for JSON response + serializable_holders = [] + for holder in holders: + staff = holder.get("staff") + serializable_holders.append( + { + "staff": { + "id": str(staff.id) if staff else None, + "name": staff.get_full_name() if staff else None, + "email": staff.email if staff else None, + "employee_id": staff.employee_id if staff else None, + }, + "staff_id": holder.get("staff_id"), + "name": holder.get("name"), + "email": holder.get("email"), + "role_field": holder.get("role_field"), + "role_label": holder.get("role_label"), + } + ) + return JsonResponse(serializable_holders, safe=False) + + @api_view(["GET"]) @permission_classes([]) def ajax_departments(request): diff --git a/apps/projects/ui_views.py b/apps/projects/ui_views.py index 405dc58..79fd222 100644 --- a/apps/projects/ui_views.py +++ b/apps/projects/ui_views.py @@ -140,7 +140,7 @@ def project_detail(request, pk): focus_phases[phase_key] = phase_obj focus_tasks[phase_key] = phase_obj.tasks.all().order_by("order", "created_at") - can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager today = timezone.now().date() context = { @@ -166,7 +166,7 @@ def project_create(request, template_pk=None): user = request.user # Check permission (PX Admin, Hospital Admin, or Department Manager) - if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager): + if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): messages.error(request, _("You don't have permission to create projects.")) return redirect("projects:project_list") @@ -309,7 +309,7 @@ def project_edit(request, pk): return redirect("projects:project_list") # Check edit permission (PX Admin, Hospital Admin, or Department Manager) - if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager): + if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): messages.error(request, _("You don't have permission to edit projects.")) return redirect("projects:project_detail", pk=project.pk) @@ -566,7 +566,7 @@ def task_toggle_status(request, project_pk, task_pk, phase=None): project = get_object_or_404(QIProject, pk=project_pk, is_template=False) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) - if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager): + if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): messages.error(request, _("You don't have permission to update task status.")) return redirect("projects:project_detail", pk=project.pk) @@ -782,7 +782,7 @@ def convert_action_to_project(request, action_pk): user = request.user # Check permission - if not (user.is_px_admin() or user.is_hospital_admin or user.is_department_manager): + if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): messages.error(request, _("You don't have permission to create projects.")) return redirect("px_action_center:action_detail", pk=action_pk) @@ -874,7 +874,7 @@ def pdca_phase_detail(request, pk, phase): ) tasks = pdca_phase.tasks.all().order_by("order", "created_at") - can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager from django.utils import timezone @@ -910,7 +910,7 @@ def pdca_phase_edit(request, pk, phase): messages.error(request, _("You don't have permission.")) return redirect("projects:project_list") - can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager if not can_edit: messages.error(request, _("You don't have permission to edit this project.")) return redirect("projects:pdca_phase_detail", pk=project.pk, phase=phase) @@ -983,7 +983,7 @@ def focus_phase_detail(request, pk, phase): ) tasks = focus_phase.tasks.all().order_by("order", "created_at") - can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager from django.utils import timezone @@ -1020,7 +1020,7 @@ def focus_phase_edit(request, pk, phase): messages.error(request, _("You don't have permission.")) return redirect("projects:project_list") - can_edit = user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + can_edit = user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager if not can_edit: messages.error(request, _("You don't have permission to edit this project.")) return redirect("projects:focus_phase_detail", pk=project.pk, phase=phase) @@ -1111,7 +1111,7 @@ def _check_project_permission(project, user): def _get_can_edit(user): """Helper to check edit permissions.""" - return user.is_px_admin() or user.is_hospital_admin or user.is_department_manager + return user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager @block_source_user diff --git a/apps/px_action_center/tasks.py b/apps/px_action_center/tasks.py index 74182ce..2aa70f1 100644 --- a/apps/px_action_center/tasks.py +++ b/apps/px_action_center/tasks.py @@ -78,11 +78,21 @@ def send_sla_reminders(): if 0 < hours_until_due <= 4: # Within 4 hours of due date # Send reminder if action.assigned_to and action.assigned_to.email: - from apps.notifications.services import send_email + from apps.notifications.services import send_email, get_email_header_html send_email( email=action.assigned_to.email, subject=f"SLA Reminder: Action Due Soon - {action.title}", message=f"Action {action.title} is due in {int(hours_until_due)} hours.", + html_message=f""" +
+ {get_email_header_html()} +
+

SLA Reminder: Action Due Soon

+

Action {action.title} is due in {int(hours_until_due)} hours.

+

Please take action before the deadline.

+
+
+""", related_object=action, metadata={'action_id': str(action.id)} ) @@ -179,11 +189,24 @@ def escalate_action(action_id): # Send notification if new_assignee and new_assignee.email: - from apps.notifications.services import send_email + from apps.notifications.services import send_email, get_email_header_html send_email( email=new_assignee.email, subject=f"Escalated Action Assigned: {action.title}", message=f"An overdue action has been escalated to you: {action.title}", + html_message=f""" +
+ {get_email_header_html()} +
+

Escalated Action Assigned

+

An overdue action has been escalated to you:

+
+

{action.title}

+
+

Please take action at your earliest convenience.

+
+
+""", related_object=action ) diff --git a/apps/px_sources/ui_views.py b/apps/px_sources/ui_views.py index d2f7183..50d5b98 100644 --- a/apps/px_sources/ui_views.py +++ b/apps/px_sources/ui_views.py @@ -865,9 +865,9 @@ def source_user_create_complaint(request): form.data["hospital"] = str(source_user.hospital.id) if not form.data.get("location"): - from apps.organizations.models import Location + from apps.organizations.models import LegacyLocation - first_location = Location.objects.first() + first_location = LegacyLocation.objects.first() if first_location: form.data = form.data.copy() form.data["location"] = str(first_location.id) @@ -887,10 +887,7 @@ def source_user_create_complaint(request): # Map complaint_details to description (form field vs model field) complaint.description = form.cleaned_data.get("complaint_details", "") - # Generate reference number - today = datetime.now().strftime("%Y%m%d") - random_suffix = str(uuid.uuid4().int)[:6] - complaint.reference_number = f"CMP-{today}-{random_suffix}" + # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN) # Set created by complaint.created_by = request.user @@ -952,9 +949,9 @@ def source_user_create_complaint(request): # Pre-populate location (get first location from user's hospital if available) if source_user.hospital: - from apps.organizations.models import Location + from apps.organizations.models import LegacyLocation - first_location = Location.objects.first() + first_location = LegacyLocation.objects.first() if first_location: form.initial["location"] = first_location.id @@ -1039,22 +1036,6 @@ def source_user_create_inquiry(request): if request.method == "POST": form = InquiryForm(request.POST, request=request, initial=initial) - from apps.organizations.models import Location, MainSection, SubSection - form.fields["location"].queryset = Location.active_locations() - - location_id = form.data.get("location") - if location_id: - available_sections = ( - SubSection.objects.filter(location_id=location_id).values_list("main_section_id", flat=True).distinct() - ) - form.fields["main_section"].queryset = MainSection.objects.filter(id__in=available_sections).order_by("name_en") - - section_id = form.data.get("main_section") - if section_id: - form.fields["subsection"].queryset = SubSection.objects.filter( - location_id=location_id, main_section_id=section_id - ).order_by("name_en") - if form.is_valid(): try: inquiry = form.save(commit=False) @@ -1111,8 +1092,8 @@ def source_user_create_inquiry(request): form.fields[field_name].widget.attrs["class"] = TAILWIND_SELECT if "location" in form.fields: - from apps.organizations.models import Location - form.fields["location"].queryset = Location.active_locations() + from apps.organizations.models import LegacyLocation + form.fields["location"].queryset = LegacyLocation.active_locations() context = { "form": form, @@ -1392,8 +1373,8 @@ def source_user_create_observation(request): if source_user.hospital: form.initial["hospital"] = source_user.hospital.id - from apps.organizations.models import Location - first_location = Location.objects.first() + from apps.organizations.models import LegacyLocation + first_location = LegacyLocation.objects.first() if first_location: form.initial["location"] = first_location.id @@ -1477,8 +1458,8 @@ def source_user_create_suggestion(request): if source_user.hospital: form.initial["hospital"] = source_user.hospital.id - from apps.organizations.models import Location - first_location = Location.objects.first() + from apps.organizations.models import LegacyLocation + first_location = LegacyLocation.objects.first() if first_location: form.initial["location"] = first_location.id @@ -1522,18 +1503,33 @@ def source_user_create_communication_request(request): for staff_user in px_employee: try: + from django.template.loader import render_to_string + + email_context = { + "patient_name": cr.patient_name, + "reason": cr.get_reason_display(), + "message": cr.message, + "source_user_name": request.user.get_full_name(), + "request_id": str(cr.id), + "detail_url": detail_url, + } + html_message = render_to_string("emails/communication_request_notification.html", email_context) + message = ( + f"A source user has submitted a communication request.\n\n" + f"Reason: {cr.get_reason_display()}\n" + f"Patient: {cr.patient_name}\n" + f"From: {request.user.get_full_name()}\n" + f"Message: {cr.message}\n\n" + f"Request ID: {cr.id}\n" + f"View and respond: {detail_url}\n" + ) NotificationService.send_email( - to_email=staff_user.email, + email=staff_user.email, subject=f"New Communication Request - {cr.get_reason_display()}", - template_name="emails/communication_request_notification", - context={ - "patient_name": cr.patient_name, - "reason": cr.get_reason_display(), - "message": cr.message, - "source_user_name": request.user.get_full_name(), - "request_id": str(cr.id), - "detail_url": detail_url, - }, + message=message, + html_message=html_message, + related_object=cr, + metadata={"notification_type": "communication_request"}, ) except Exception: pass diff --git a/apps/rca/views.py b/apps/rca/views.py index 1c42576..449bcb4 100644 --- a/apps/rca/views.py +++ b/apps/rca/views.py @@ -21,19 +21,78 @@ from django.views.generic import ( ) +def _same_hospital(user, rca, request=None): + if user.is_px_admin(): + tenant = getattr(request, "tenant_hospital", None) if request else None + if tenant: + return rca.hospital_id == tenant.id + return True + return user.hospital_id and rca.hospital_id == user.hospital_id + + +def _dept_match(user, rca): + if not rca.department_id: + return False + if user.is_department_manager() and user.department_id == rca.department_id: + return True + if user.is_champion() and user.department_id == rca.department_id: + return True + if user.is_director() and user.get_directed_departments().filter(id=rca.department_id).exists(): + return True + return False + + def _check_rca_access(request, rca): user = request.user if user.is_superuser: return - if user.is_px_admin(): - tenant = getattr(request, "tenant_hospital", None) - if tenant and rca.hospital_id == tenant.id: - return - elif user.hospital and rca.hospital_id == user.hospital.id: + if user.is_px_admin() and _same_hospital(user, rca, request): + return + if user.is_hospital_admin() and _same_hospital(user, rca): + return + if user.is_px_management() and _same_hospital(user, rca): + return + if user.is_px_employee() and _same_hospital(user, rca): + return + if _dept_match(user, rca): + return + if rca.assigned_to == user: return raise PermissionDenied("You don't have access to this RCA.") +def _check_rca_admin(request, rca): + user = request.user + if user.is_superuser: + return + if user.is_px_admin() and _same_hospital(user, rca, request): + return + if user.is_hospital_admin() and _same_hospital(user, rca): + return + raise PermissionDenied("Only administrators can perform this action.") + + +def _check_rca_create(request): + user = request.user + if user.is_superuser: + return + if user.is_px_admin(): + return + if user.is_hospital_admin(): + return + if user.is_px_management(): + return + if user.is_px_employee(): + return + if user.is_department_manager(): + return + if user.is_champion(): + return + if user.is_director(): + return + raise PermissionDenied("You don't have permission to create RCAs.") + + from .forms import ( RCAAttachmentForm, RCAClosureForm, @@ -93,14 +152,33 @@ class RCAListView(LoginRequiredMixin, ListView): if date_to: queryset = queryset.filter(created_at__lte=date_to) - # Filter by user's hospital (if not admin) + # Filter by user's hospital and role user = self.request.user if user.is_px_admin(): tenant = getattr(self.request, "tenant_hospital", None) if tenant: queryset = queryset.filter(hospital=tenant) - elif user.hospital: - queryset = queryset.filter(hospital=user.hospital) + elif user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(): + if user.hospital: + queryset = queryset.filter(hospital=user.hospital) + else: + queryset = queryset.none() + elif user.is_department_manager() or user.is_champion(): + if user.hospital and user.department: + queryset = queryset.filter(hospital=user.hospital, department=user.department) + else: + queryset = queryset.none() + elif user.is_director(): + if user.hospital: + directed_depts = user.get_directed_departments() + queryset = queryset.filter(hospital=user.hospital, department__in=directed_depts) + else: + queryset = queryset.none() + elif user.is_executive(): + if user.hospital: + queryset = queryset.filter(hospital=user.hospital) + else: + queryset = queryset.none() else: queryset = queryset.none() @@ -149,8 +227,17 @@ class RCADetailView(LoginRequiredMixin, DetailView): tenant = getattr(self.request, "tenant_hospital", None) if tenant: return queryset.filter(hospital=tenant) - elif user.hospital: - return queryset.filter(hospital=user.hospital) + return queryset + elif user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_executive(): + if user.hospital: + return queryset.filter(hospital=user.hospital) + elif user.is_department_manager() or user.is_champion(): + if user.hospital and user.department: + return queryset.filter(hospital=user.hospital, department=user.department) + elif user.is_director(): + if user.hospital: + directed_depts = user.get_directed_departments() + return queryset.filter(hospital=user.hospital, department__in=directed_depts) return queryset.none() def get_context_data(self, **kwargs): @@ -161,6 +248,26 @@ class RCADetailView(LoginRequiredMixin, DetailView): completed_actions = self.object.corrective_actions.filter(status=RCAActionStatus.COMPLETED).count() context["progress_percentage"] = (completed_actions / total_actions * 100) if total_actions > 0 else 0 + rca = self.object + user = self.request.user + try: + _check_rca_access(self.request, rca) + context["can_edit"] = True + except PermissionDenied: + context["can_edit"] = False + try: + _check_rca_admin(self.request, rca) + context["can_approve"] = True + context["can_delete"] = True + except PermissionDenied: + context["can_approve"] = False + context["can_delete"] = False + context["can_create"] = True + try: + _check_rca_create(self.request) + except PermissionDenied: + context["can_create"] = False + if self.object.content_type and self.object.object_id: try: model_class = self.object.content_type.model_class() @@ -181,6 +288,10 @@ class RCACreateView(LoginRequiredMixin, CreateView): template_name = "rca/rca_form.html" success_url = reverse_lazy("rca:rca_list") + def dispatch(self, request, *args, **kwargs): + _check_rca_create(request) + return super().dispatch(request, *args, **kwargs) + def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["user"] = self.request.user @@ -294,7 +405,7 @@ class RCADeleteView(LoginRequiredMixin, View): def post(self, request, pk): rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False) - _check_rca_access(request, rca) + _check_rca_admin(request, rca) rca.soft_delete(user=request.user) messages.success(request, "Root Cause Analysis deleted successfully!") return redirect("rca:rca_list") @@ -340,7 +451,7 @@ class RCAApprovalView(LoginRequiredMixin, View): def post(self, request, pk): rca = get_object_or_404(RootCauseAnalysis, pk=pk, is_deleted=False, status=RCAStatus.REVIEW) - _check_rca_access(request, rca) + _check_rca_admin(request, rca) form = RCAApprovalForm(request.POST) if form.is_valid(): @@ -372,7 +483,7 @@ class RCAClosureView(LoginRequiredMixin, View): rca = get_object_or_404( RootCauseAnalysis, pk=pk, is_deleted=False, status__in=[RCAStatus.APPROVED, RCAStatus.IN_PROGRESS] ) - _check_rca_access(request, rca) + _check_rca_admin(request, rca) form = RCAClosureForm(request.POST) if form.is_valid(): diff --git a/apps/reports/models.py b/apps/reports/models.py index d057f19..023a16f 100644 --- a/apps/reports/models.py +++ b/apps/reports/models.py @@ -188,6 +188,11 @@ class ReportSchedule(UUIDModel, TimeStampedModel): def __str__(self): return f"{self.report.name} - {self.get_frequency_display()}" + def save(self, *args, **kwargs): + if self.is_active and not self.next_run_at: + self.next_run_at = self.calculate_next_run() + super().save(*args, **kwargs) + def calculate_next_run(self): """Calculate the next run time based on frequency.""" from datetime import datetime, timedelta diff --git a/apps/reports/tasks.py b/apps/reports/tasks.py index 0463ae1..4356262 100644 --- a/apps/reports/tasks.py +++ b/apps/reports/tasks.py @@ -11,6 +11,12 @@ def process_scheduled_reports(): from apps.reports.models import ReportSchedule now = timezone.now() + + for schedule in ReportSchedule.objects.filter(is_active=True, next_run_at__isnull=True): + schedule.next_run_at = schedule.calculate_next_run() + if schedule.next_run_at: + schedule.save(update_fields=["next_run_at"]) + schedules = ReportSchedule.objects.filter( is_active=True, next_run_at__lte=now, @@ -29,7 +35,7 @@ def process_scheduled_reports(): def generate_and_deliver_report(schedule_id): from apps.reports.models import ReportSchedule, GeneratedReport from apps.reports.services import ReportBuilderService - from apps.notifications.services import NotificationService + from apps.notifications.services import NotificationService, get_email_header_html try: schedule = ReportSchedule.objects.select_related("report").get(id=schedule_id) @@ -102,6 +108,20 @@ def generate_and_deliver_report(schedule_id): message=message, related_object=generated, notification_type="report", + html_message=f""" +
+ {get_email_header_html()} +
+

Scheduled Report: {report.name}

+ + + + +
Data Source:{report.get_data_source_display()}
Rows:{row_count}
Generated:{timezone.now().strftime('%Y-%m-%d %H:%M')}
+

View the full report in the PX360 dashboard.

+
+
+""", ) except Exception as email_err: logger.error(f"Failed to send report email to {recipient}: {email_err}") diff --git a/apps/standards/views.py b/apps/standards/views.py index 4a2ce4d..3d102d5 100644 --- a/apps/standards/views.py +++ b/apps/standards/views.py @@ -84,7 +84,7 @@ class StandardViewSet(viewsets.ModelViewSet): if user.is_px_admin(): return queryset if user.hospital: - return queryset.filter(department__hospital=user.hospital) + return queryset.filter(departments__hospital=user.hospital) return queryset.none() diff --git a/config/urls.py b/config/urls.py index d73fcbc..e413347 100644 --- a/config/urls.py +++ b/config/urls.py @@ -56,6 +56,8 @@ urlpatterns = [ path("api/simulator/", include("apps.simulator.urls", namespace="api_simulator")), path("api/patients/search/", api_patient_search, name="api_patient_search"), path("api/staffs/search/", api_staff_search, name="api_staff_search"), + # External API (X-API-Key authenticated) + path("api/v1/external/", include("apps.integrations.urls_external")), # OpenAPI/Swagger documentation path("api/schema/", SpectacularAPIView.as_view(), name="schema"), path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), diff --git a/data/Final List of Departments - 4th Version.xlsx b/data/Final List of Departments - 4th Version.xlsx new file mode 100644 index 0000000..6ac20b8 Binary files /dev/null and b/data/Final List of Departments - 4th Version.xlsx differ diff --git a/data/Final List of Departments - 6th Version.xlsx b/data/Final List of Departments - 6th Version.xlsx new file mode 100644 index 0000000..36d239e Binary files /dev/null and b/data/Final List of Departments - 6th Version.xlsx differ diff --git a/data/employees.xlsx b/data/employees.xlsx new file mode 100644 index 0000000..e0de298 Binary files /dev/null and b/data/employees.xlsx differ diff --git a/dep.md b/dep.md new file mode 100644 index 0000000..4fa7dac --- /dev/null +++ b/dep.md @@ -0,0 +1,587 @@ +# PX360 Organizational Structure Refactor (Final Architecture) + +## Objective + +Refactor the current Department Hierarchy implementation into a simpler and more maintainable model that: + +* Uses a single source of truth for routing. +* Uses a single source of truth for reporting. +* Preserves legacy complaint mappings for historical records. +* Removes unnecessary hierarchy levels. +* Aligns with the latest organizational spreadsheet. +* Supports future department and section changes without impacting reporting. + +--- + +# Key Business Findings + +After reviewing: + +* Legacy complaint hierarchy +* Existing reports +* Final organization spreadsheet + +the following conclusions were reached. + +## Legacy Structure + +Historical complaints used: + +Location +→ Main Section +→ Sub Section + +Example: + +Outpatient +→ Medical +→ Radiology + +The old Sub Section was the actual operational unit receiving complaints. + +--- + +## Current Organization Structure + +The latest organization file contains: + +* Main Section +* Department +* Section + +Examples: + +Medical +→ Pharmacy Department +→ Central Outpatient Pharmacy + +Medical +→ Pharmacy Department +→ General + +--- + +## Important Discovery + +Area is not a valid hierarchy level. + +Evidence: + +* Frequently empty +* Often duplicates Department concepts +* Not used in reporting +* Not used in routing +* Not used in ownership + +Therefore: + +Area should not become a first-class model. + +--- + +## Reporting Structure + +All management reports are grouped by: + +* Medical +* Non-Medical +* Nursing +* Support Services + +These values come from: + +Main Section + +Therefore Main Section is actually the reporting category. + +--- + +# Target Hierarchy + +## Department Category + +Represents: + +* Medical +* Non-Medical +* Nursing +* Support Services + +Model: + +```python +class DepartmentCategory(models.TextChoices): + MEDICAL = "medical" + NON_MEDICAL = "non_medical" + NURSING = "nursing" + SUPPORT_SERVICES = "support_services" +``` + +This replaces the old concept of: + +Main Section + +--- + +## Department + +Primary accountable organizational unit. + +Model: + +```python +class Department(models.Model): + + category + + name + code + + champion + + manager_1st + manager_2nd + manager_3rd + + deputy_manager + supervisor + deputy_supervisor + + is_active +``` + +Examples: + +* Pharmacy Department +* Radiology Department +* Laboratory Department +* Emergency Department + +Departments always have ownership. + +Departments always have a Champion. + +Departments are the fallback routing target. + +--- + +## Section + +Optional child unit under a Department. + +Model: + +```python +class Section(models.Model): + + department = models.ForeignKey( + Department, + related_name="sections" + ) + + name + code + + champion = models.ForeignKey( + Staff, + null=True, + blank=True + ) + + location_type + + location_code + zone + floor + + is_active +``` + +Examples: + +* Central Outpatient Pharmacy +* ER Pharmacy +* Pediatric Pharmacy + +Not every Department requires Sections. + +Not every Section requires a Champion. + +--- + +## Remove Sub-Section + +Current model: + +OrgSubSubSection + +Findings: + +* Mostly empty in source data. +* Not used in reports. +* Not used in routing. +* Not used in escalation. + +Action: + +Deprecate and remove. + +--- + +# Complaint Model + +## Target Structure + +```python +class Complaint(models.Model): + + department = models.ForeignKey( + Department + ) + + section = models.ForeignKey( + Section, + null=True, + blank=True + ) + + legacy_mapping = models.ForeignKey( + LegacyHierarchyMapping, + null=True, + blank=True + ) +``` + +--- + +# Ownership Rules + +Single ownership strategy. + +```python +def get_owner(): + + if complaint.section and complaint.section.champion: + return complaint.section.champion + + return complaint.department.champion +``` + +Rules: + +1. Section Champion takes ownership when available. +2. Otherwise Department Champion owns the complaint. + +This becomes the only routing rule in the system. + +--- + +# Legacy Data Strategy + +## Legacy Mapping Table + +Keep: + +```python +class LegacyHierarchyMapping(models.Model): + + old_location + old_main_section + old_subsection + + department + section +``` + +Purpose: + +Legacy Complaint +→ Legacy Mapping +→ Department / Section + +--- + +## Legacy Fields + +The following fields become historical reference only: + +* legacy_location +* legacy_main_section +* legacy_subsection + +Rules: + +* No reporting usage +* No routing usage +* No escalation usage +* No dashboard usage + +May remain temporarily for audit purposes. + +--- + +# Reporting Strategy + +## Single Source of Truth + +All reports must use: + +```python +complaint.department +complaint.section +``` + +only. + +Never use legacy hierarchy fields. + +--- + +## Category Reports + +Medical / Nursing / Support Services reports: + +```python +Complaint.objects.values( + "department__category" +) +``` + +--- + +## Department Reports + +```python +Complaint.objects.values( + "department__name" +) +``` + +--- + +## Section Reports + +```python +Complaint.objects.values( + "section__name" +) +``` + +--- + +# Location Handling + +Location is metadata. + +It is not part of ownership hierarchy. + +Examples: + +* Outpatient Clinics +* Inpatient +* Emergency +* General Services + +Used for: + +* Display +* Filtering +* Analytics + +Not used for routing ownership. + +--- + +## Location Type + +Retain: + +* OP +* IP +* ER +* GENERAL + +This becomes the preferred reporting dimension instead of string matching location names. + +Example: + +```python +class LocationType(models.TextChoices): + OP = "OP" + IP = "IP" + ER = "ER" + GENERAL = "GENERAL" +``` + +--- + +# Import Strategy + +Department uniqueness: + +```python +(category, department_name) +``` + +must create a single Department. + +Never create duplicate Departments from repeated spreadsheet rows. + +--- + +Sections: + +```python +(department, section_name) +``` + +must be unique. + +--- + +# Migration Plan + +## Phase 1 + +Create: + +* DepartmentCategory +* LocationType enums + +Remove Area dependency. + +--- + +## Phase 2 + +Rename: + +OrgSubSection + +to: + +Section + +Update: + +* models +* serializers +* views +* APIs +* templates +* services + +--- + +## Phase 3 + +Deprecate: + +OrgSubSubSection + +Backfill any valid data. + +Remove references. + +--- + +## Phase 4 + +Backfill all historical complaints. + +Populate: + +* department +* section + +using LegacyHierarchyMapping. + +Target: + +100% coverage. + +--- + +## Phase 5 + +Update routing services. + +Replace all routing logic with: + +```python +complaint.get_owner() +``` + +--- + +## Phase 6 + +Update: + +* Dashboards +* KPI services +* Reports +* Exports +* Analytics + +to use: + +* Department +* Section +* Department Category + +only. + +--- + +## Phase 7 + +Update forms. + +Replace: + +Location +→ Main Section +→ Sub Section + +with: + +Category +→ Department +→ Section + +Section optional. + +--- + +## Phase 8 + +Mark legacy hierarchy fields as deprecated. + +Prevent all new writes. + +Keep read-only until final cleanup release. + +--- + +# Success Criteria + +✓ Department is the primary accountable entity. + +✓ Section is optional. + +✓ Reporting uses Department Category. + +✓ Routing uses Department / Section only. + +✓ Legacy hierarchy is used only for historical mapping. + +✓ Area is removed from operational design. + +✓ OrgSubSubSection is removed. + +✓ OrgSubSection is renamed to Section. + +✓ Dashboards no longer depend on legacy hierarchy. + +✓ Complaint ownership has a single source of truth. diff --git a/docs/PX360_External_API.postman_collection.json b/docs/PX360_External_API.postman_collection.json new file mode 100644 index 0000000..3a643fc --- /dev/null +++ b/docs/PX360_External_API.postman_collection.json @@ -0,0 +1,799 @@ +{ + "info": { + "_postman_id": "px360-external-api-collection", + "name": "PX360 External API", + "description": "PX360 Patient Experience Platform - External API endpoints.\n\nBase URL: `/api/v1/external/`\n\nAll requests require an `X-API-Key` header. Set the `api_key` variable in your environment.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Lookup", + "item": [ + { + "name": "List Hospitals", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/hospitals/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "hospitals", ""] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/v1/external/hospitals/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "hospitals", ""] + } + }, + "status": "OK", + "code": 200, + "body": "{\n \"count\": 3,\n \"results\": [\n { \"id\": \"uuid\", \"name\": \"Al Nuzha\", \"code\": \"HH-N\" },\n { \"id\": \"uuid\", \"name\": \"Al Olya\", \"code\": \"HH-A\" },\n { \"id\": \"uuid\", \"name\": \"Al Suwaidi\", \"code\": \"HH-S\" }\n ]\n}" + } + ] + }, + { + "name": "List Location Types", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/location-types/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "location-types", ""] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/v1/external/location-types/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "location-types", ""] + } + }, + "status": "OK", + "code": 200, + "body": "{\n \"count\": 4,\n \"results\": [\n { \"value\": \"OP\", \"label\": \"Outpatient\", \"label_ar\": \"خارجي\" },\n { \"value\": \"IP\", \"label\": \"Inpatient\", \"label_ar\": \"تنويم\" },\n { \"value\": \"ER\", \"label\": \"Emergency\", \"label_ar\": \"طوارئ\" },\n { \"value\": \"GENERAL\", \"label\": \"General\", \"label_ar\": \"عام\" }\n ]\n}" + } + ] + }, + { + "name": "List Areas", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/areas/?hospital=Al Nuzha", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "areas", ""], + "query": [ + { + "key": "hospital", + "value": "Al Nuzha" + }, + { + "key": "location_type", + "value": "OP", + "disabled": true + } + ] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/v1/external/areas/?hospital=Al Nuzha", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "areas", ""], + "query": [ + { "key": "hospital", "value": "Al Nuzha" } + ] + } + }, + "status": "OK", + "code": 200, + "body": "{\n \"count\": 27,\n \"results\": [\n { \"id\": \"uuid\", \"name\": \"Emergency\", \"name_ar\": \"\", \"code\": \"emergency\" }\n ]\n}" + } + ] + }, + { + "name": "List Departments", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/departments/?hospital=Al Nuzha", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "departments", ""], + "query": [ + { + "key": "hospital", + "value": "Al Nuzha" + } + ] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/v1/external/departments/?hospital=Al Nuzha", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "departments", ""], + "query": [ + { "key": "hospital", "value": "Al Nuzha" } + ] + } + }, + "status": "OK", + "code": 200, + "body": "{\n \"count\": 37,\n \"results\": [\n { \"id\": \"uuid\", \"name\": \"Critical Care Department\", \"name_en\": \"Critical Care Department\", \"name_ar\": \"\", \"code\": \"hh_n_critical_care_department\" }\n ]\n}" + } + ] + }, + { + "name": "List Sections", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/sections/?hospital=Al Nuzha&department=Critical Care Department", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "sections", ""], + "query": [ + { + "key": "hospital", + "value": "Al Nuzha" + }, + { + "key": "department", + "value": "Critical Care Department" + } + ] + } + }, + "response": [ + { + "name": "Success", + "originalRequest": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/v1/external/sections/?hospital=Al Nuzha&department=Critical Care Department", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "sections", ""], + "query": [ + { "key": "hospital", "value": "Al Nuzha" }, + { "key": "department", "value": "Critical Care Department" } + ] + } + }, + "status": "OK", + "code": 200, + "body": "{\n \"count\": 3,\n \"results\": [\n { \"id\": \"uuid\", \"name\": \"ICU\", \"name_ar\": \"\", \"code\": \"hh_n_critical_care_department__icu\" }\n ]\n}" + } + ] + } + ] + }, + { + "name": "Complaints", + "item": [ + { + "name": "Create Complaint", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital\": \"Al Nuzha\",\n \"title\": \"Long wait time in ER\",\n \"description\": \"I waited 3 hours in the emergency room without being seen.\",\n \"contact_name\": \"John Doe\",\n \"contact_phone\": \"+966501234567\",\n \"contact_email\": \"john@example.com\",\n \"location_type\": \"ER\",\n \"area\": \"Emergency\",\n \"department\": \"Critical Care Department\",\n \"section\": \"ICU\",\n \"relation_to_patient\": \"patient\",\n \"patient_name\": \"John Doe\",\n \"incident_date\": \"2026-06-10\",\n \"expected_result\": \"Faster service in ER\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/complaints/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "complaints", ""] + } + }, + "response": [ + { + "name": "Created", + "originalRequest": { + "method": "POST", + "url": { + "raw": "{{base_url}}/api/v1/external/complaints/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "complaints", ""] + } + }, + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"reference_number\": \"CMP-20260609-167152\",\n \"status\": \"open\"\n}" + } + ] + }, + { + "name": "List Complaints", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/complaints/list/?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "complaints", "list", ""], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + }, + { + "key": "created_from", + "value": "2026-01-01", + "disabled": true + }, + { + "key": "created_to", + "value": "2026-06-30", + "disabled": true + }, + { + "key": "status", + "value": "open", + "disabled": true + } + ] + } + }, + "response": [] + }, + { + "name": "Get Complaint", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/complaints/CMP-20260609-167152/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "complaints", "CMP-20260609-167152", ""] + } + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"id\": \"uuid\",\n \"reference_number\": \"CMP-20260609-167152\",\n \"title\": \"Long wait time in ER\",\n \"description\": \"I waited 3 hours...\",\n \"status\": \"open\",\n \"severity\": \"medium\",\n \"priority\": \"medium\",\n \"contact_name\": \"John Doe\",\n \"contact_phone\": \"+966501234567\",\n \"hospital_name\": \"Al Nuzha\",\n \"created_at\": \"2026-06-09T13:38:03.808618+03:00\",\n \"updated_at\": \"2026-06-09T13:38:03.808683+03:00\"\n}" + } + ] + }, + { + "name": "Set Satisfaction", + "request": { + "method": "PATCH", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"satisfaction\": \"satisfied\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/complaints/CMP-20260609-167152/satisfaction/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "complaints", "CMP-20260609-167152", "satisfaction", ""] + } + }, + "response": [ + { + "name": "Success", + "status": "OK", + "code": 200, + "body": "{\n \"success\": true,\n \"reference_number\": \"CMP-20260609-167152\",\n \"satisfaction\": \"satisfied\"\n}" + } + ] + } + ] + }, + { + "name": "Inquiries", + "item": [ + { + "name": "Create Inquiry", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital\": \"Al Nuzha\",\n \"subject\": \"Appointment rescheduling\",\n \"message\": \"I need to reschedule my cardiology appointment.\",\n \"contact_name\": \"Jane Smith\",\n \"contact_phone\": \"+966509876543\",\n \"contact_email\": \"jane@example.com\",\n \"category\": \"appointment\",\n \"location_type\": \"OP\",\n \"department\": \"Cardiology\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/inquiries/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "inquiries", ""] + } + }, + "response": [ + { + "name": "Created", + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"reference_number\": \"INQ-20260609-124293\",\n \"status\": \"open\"\n}" + } + ] + }, + { + "name": "List Inquiries", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/inquiries/list/?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "inquiries", "list", ""], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Inquiry", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/inquiries/INQ-20260609-124293/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "inquiries", "INQ-20260609-124293", ""] + } + }, + "response": [] + } + ] + }, + { + "name": "Observations", + "item": [ + { + "name": "Create Observation", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital\": \"Al Nuzha\",\n \"description\": \"Wet floor near the main entrance, no warning sign posted.\",\n \"title\": \"Wet floor hazard\",\n \"severity\": \"medium\",\n \"location_text\": \"Main entrance lobby\",\n \"contact_name\": \"Anonymous Reporter\",\n \"contact_phone\": \"+966501112233\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/observations/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "observations", ""] + } + }, + "response": [ + { + "name": "Created", + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"reference_number\": \"OBS-O8TCIC\",\n \"status\": \"new\"\n}" + } + ] + }, + { + "name": "List Observations", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/observations/list/?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "observations", "list", ""], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Observation", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/observations/OBS-O8TCIC/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "observations", "OBS-O8TCIC", ""] + } + }, + "response": [] + } + ] + }, + { + "name": "Appreciations", + "item": [ + { + "name": "Create Appreciation", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital\": \"Al Nuzha\",\n \"message\": \"Dr. Ahmed was incredibly kind and thorough during my visit.\",\n \"contact_name\": \"Sara Ali\",\n \"contact_phone\": \"+966503334455\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/appreciations/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "appreciations", ""] + } + }, + "response": [ + { + "name": "Created", + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"reference_number\": \"APR-20260609-239270\",\n \"status\": \"draft\"\n}" + } + ] + }, + { + "name": "List Appreciations", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/appreciations/list/?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "appreciations", "list", ""], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Appreciation", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/appreciations//", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "appreciations", "", ""] + } + }, + "response": [] + } + ] + }, + { + "name": "Suggestions", + "item": [ + { + "name": "Create Suggestion", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital\": \"Al Nuzha\",\n \"message\": \"Please implement a digital queue management system to reduce wait times.\",\n \"contact_name\": \"Suggestor\",\n \"contact_phone\": \"+966504445566\",\n \"title\": \"Digital queue system\",\n \"category\": \"technology\",\n \"rating\": 4\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/suggestions/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "suggestions", ""] + } + }, + "response": [ + { + "name": "Created", + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"reference_number\": \"SG-20260609-316316\",\n \"status\": \"submitted\"\n}" + } + ] + }, + { + "name": "List Suggestions", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/suggestions/list/?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "suggestions", "list", ""], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Suggestion", + "request": { + "method": "GET", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + } + ], + "url": { + "raw": "{{base_url}}/api/v1/external/suggestions//", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "suggestions", "", ""] + } + }, + "response": [] + } + ] + }, + { + "name": "Doctor Ratings", + "item": [ + { + "name": "Submit Doctor Rating", + "request": { + "method": "POST", + "header": [ + { + "key": "X-API-Key", + "value": "{{api_key}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"hospital_id\": 1,\n \"doctor_id\": \"10738\",\n \"doctor_name\": \"Dr. Omaymah Yaqoub\",\n \"rating\": 4,\n \"feedback\": \"Great doctor, very caring and attentive.\",\n \"rating_date\": \"2026-06-10\",\n \"patient_uhid\": \"UHID12345\",\n \"patient_name\": \"Ahmed Ali\",\n \"patient_type\": \"OP\",\n \"department_name\": \"Internal Medicine\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/v1/external/doctor-ratings/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "doctor-ratings", ""] + } + }, + "response": [ + { + "name": "Created", + "originalRequest": { + "method": "POST", + "url": { + "raw": "{{base_url}}/api/v1/external/doctor-ratings/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "doctor-ratings", ""] + } + }, + "status": "Created", + "code": 201, + "body": "{\n \"success\": true,\n \"rating_id\": \"a1b2c3d4-e5f6-7890-abcd-ef1234567890\",\n \"staff_id\": \"f1e2d3c4-b5a6-7890-abcd-ef1234567890\",\n \"doctor_name\": \"OMAYMAH YAQOUB ELAMEIAN\",\n \"rating\": 4\n}" + }, + { + "name": "Doctor Not Found", + "originalRequest": { + "method": "POST", + "url": { + "raw": "{{base_url}}/api/v1/external/doctor-ratings/", + "host": ["{{base_url}}"], + "path": ["api", "v1", "external", "doctor-ratings", ""] + } + }, + "status": "Bad Request", + "code": 400, + "body": "{\n \"doctor_id\": [\n \"Doctor with employee ID '99999' not found at this hospital.\"\n ]\n}" + } + ] + } + ] + } + ], + "variable": [ + { + "key": "base_url", + "value": "http://localhost:8000" + }, + { + "key": "api_key", + "value": "your-api-key-here" + } + ] +} \ No newline at end of file diff --git a/docs/external-api.md b/docs/external-api.md new file mode 100644 index 0000000..8b6c838 --- /dev/null +++ b/docs/external-api.md @@ -0,0 +1,673 @@ +# PX360 External API Documentation + +Base URL: `/api/v1/external/` + +## Authentication + +All requests require an `X-API-Key` header. + +``` +X-API-Key: +``` + +API keys are managed through the Django admin panel. Each key can be scoped to: + +- A specific hospital (restricts data access to that hospital) +- Specific entities (`complaints`, `inquiries`, `observations`, `appreciations`, `suggestions`, `doctor_ratings`) +- Empty `allowed_entities` = access to all entities + +### Creating an API Key + +Via Django admin or programmatically: + +```python +from apps.integrations.models import ExternalAPIKey + +api_key, raw_key = ExternalAPIKey.create_key( + name="Partner Integration", + hospital=hospital_object, # Optional: scope to a hospital + allowed_entities=["complaints", "inquiries"], # Optional: empty = all + rate_limit=60, # Requests per minute + description="Integration with partner system", +) +# raw_key is shown ONCE - store it securely +``` + +### Error Responses + +| HTTP Status | Meaning | +|---|---| +| `401` | Missing or invalid API key | +| `403` | API key does not have access to the requested entity, or hospital scope mismatch | +| `404` | Requested resource not found | +| `400` | Validation error (missing/invalid fields) | + +--- + +## Lookup Endpoints + +These endpoints provide dropdown/reference data for building forms. + +### List Hospitals + +``` +GET /api/v1/external/hospitals/ +``` + +**Response:** + +```json +{ + "count": 3, + "results": [ + { "id": "uuid", "name": "Al Nuzha", "code": "HH-N" }, + { "id": "uuid", "name": "Al Olya", "code": "HH-A" }, + { "id": "uuid", "name": "Al Suwaidi", "code": "HH-S" } + ] +} +``` + +### List Location Types + +``` +GET /api/v1/external/location-types/ +``` + +**Response:** + +```json +{ + "count": 4, + "results": [ + { "value": "OP", "label": "Outpatient", "label_ar": "خارجي" }, + { "value": "IP", "label": "Inpatient", "label_ar": "تنويم" }, + { "value": "ER", "label": "Emergency", "label_ar": "طوارئ" }, + { "value": "GENERAL", "label": "General", "label_ar": "عام" } + ] +} +``` + +### List Areas + +``` +GET /api/v1/external/areas/?hospital= +``` + +| Parameter | Required | Description | +|---|---|---| +| `hospital` | Yes | Hospital name (case-insensitive) | +| `location_type` | No | Filter by location type (`OP`, `IP`, `ER`, `GENERAL`) | + +**Response:** + +```json +{ + "count": 27, + "results": [ + { "id": "uuid", "name": "Emergency", "name_ar": "", "code": "emergency" } + ] +} +``` + +### List Departments + +``` +GET /api/v1/external/departments/?hospital= +``` + +| Parameter | Required | Description | +|---|---|---| +| `hospital` | Yes | Hospital name (case-insensitive) | + +**Response:** + +```json +{ + "count": 37, + "results": [ + { "id": "uuid", "name": "Critical Care Department", "name_en": "Critical Care Department", "name_ar": "", "code": "hh_n_critical_care_department" } + ] +} +``` + +### List Sections + +``` +GET /api/v1/external/sections/?hospital=&department= +``` + +| Parameter | Required | Description | +|---|---|---| +| `hospital` | Yes | Hospital name (case-insensitive) | +| `department` | Yes | Department name (case-insensitive) | + +**Response:** + +```json +{ + "count": 3, + "results": [ + { "id": "uuid", "name": "ICU", "name_ar": "", "code": "hh_n_critical_care_department__icu" } + ] +} +``` + +--- + +## Complaints + +### Create a Complaint + +``` +POST /api/v1/external/complaints/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital` | Yes | string | Hospital name (case-insensitive) | +| `title` | Yes | string | Complaint title (max 500 chars) | +| `description` | Yes | string | Detailed complaint description | +| `contact_name` | Yes | string | Reporter's name (max 200 chars) | +| `contact_phone` | Yes | string | Reporter's phone (max 20 chars) | +| `contact_email` | No | string | Reporter's email | +| `relation_to_patient` | No | string | One of: `patient`, `relative`, `friend`, `other` | +| `patient_name` | No | string | Patient name | +| `national_id` | No | string | National ID number | +| `incident_date` | No | date | Date of incident (`YYYY-MM-DD`) | +| `expected_result` | No | string | Expected resolution | +| `location_type` | No | string | One of: `OP`, `IP`, `ER`, `GENERAL` | +| `area` | No | string | Area name (resolved by hospital) | +| `department` | No | string | Department name (resolved by hospital) | +| `section` | No | string | Section name (requires department) | + +**Example:** + +```json +{ + "hospital": "Al Nuzha", + "title": "Long wait time in ER", + "description": "I waited 3 hours in the emergency room without being seen.", + "contact_name": "John Doe", + "contact_phone": "+966501234567", + "contact_email": "john@example.com", + "location_type": "ER", + "area": "Emergency", + "department": "Critical Care Department", + "section": "ICU" +} +``` + +**Response (201):** + +```json +{ + "success": true, + "reference_number": "CMP-20260609-167152", + "status": "open" +} +``` + +### List Complaints + +``` +GET /api/v1/external/complaints/list/ +``` + +| Parameter | Required | Description | +|---|---|---| +| `page` | No | Page number (default: 1) | +| `page_size` | No | Items per page (default: 20, max: 100) | +| `created_from` | No | Filter by created date from (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS`) | +| `created_to` | No | Filter by created date to (date-only values auto-extend to end of day) | +| `updated_from` | No | Filter by updated date from | +| `updated_to` | No | Filter by updated date to | +| `status` | No | Filter by status value | + +### Retrieve a Complaint + +``` +GET /api/v1/external/complaints// +``` + +**Response:** + +```json +{ + "id": "uuid", + "reference_number": "CMP-20260609-167152", + "title": "Long wait time in ER", + "description": "I waited 3 hours...", + "status": "open", + "severity": "medium", + "priority": "medium", + "contact_name": "John Doe", + "contact_phone": "+966501234567", + "contact_email": "john@example.com", + "relation_to_patient": "", + "patient_name": "", + "incident_date": null, + "expected_result": "", + "resolution": "", + "satisfaction": "", + "hospital_name": "Al Nuzha", + "created_at": "2026-06-09T13:38:03.808618+03:00", + "updated_at": "2026-06-09T13:38:03.808683+03:00" +} +``` + +#### Resolution & Satisfaction Fields + +| Field | Description | +|---|---| +| `resolution` | Free-text description of the resolution taken (empty if unresolved) | +| `satisfaction` | Patient satisfaction: `satisfied`, `neutral`, `dissatisfied`, `no_response`, or empty | + +### Set Patient Satisfaction + +Sets the patient satisfaction level for a complaint. **A resolution must be recorded first** — returns `400` if the complaint has no resolution. + +``` +PATCH /api/v1/external/complaints//satisfaction/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `satisfaction` | Yes | string | One of: `satisfied`, `neutral`, `dissatisfied`, `no_response` | + +**Example:** + +```json +{ + "satisfaction": "satisfied" +} +``` + +**Response (200):** + +```json +{ + "success": true, + "reference_number": "CMP-20260609-167152", + "satisfaction": "satisfied" +} +``` + +--- + +## Inquiries + +### Create an Inquiry + +``` +POST /api/v1/external/inquiries/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital` | Yes | string | Hospital name | +| `subject` | Yes | string | Inquiry subject (max 500 chars) | +| `message` | Yes | string | Inquiry message | +| `contact_name` | Yes | string | Contact name | +| `contact_phone` | Yes | string | Contact phone | +| `contact_email` | No | string | Contact email | +| `category` | No | string | One of: `appointment`, `billing`, `medical_records`, `general`, `other` | +| `location_type` | No | string | One of: `OP`, `IP`, `ER`, `GENERAL` | +| `area` | No | string | Area name | +| `department` | No | string | Department name | +| `section` | No | string | Section name (requires department) | + +**Response (201):** + +```json +{ + "success": true, + "reference_number": "INQ-20260609-124293", + "status": "open" +} +``` + +### List / Retrieve Inquiries + +``` +GET /api/v1/external/inquiries/list/ +GET /api/v1/external/inquiries// +``` + +Same pagination and date/status filters as complaints. + +**Retrieve Response:** + +```json +{ + "id": "uuid", + "reference_number": "INQ-20260609-124293", + "subject": "Appointment rescheduling", + "message": "I need to reschedule my cardiology appointment.", + "category": "appointment", + "status": "open", + "contact_name": "Jane Smith", + "contact_phone": "+966509876543", + "contact_email": "jane@example.com", + "hospital_name": "Al Nuzha", + "created_at": "2026-06-09T13:36:22.730962+03:00", + "updated_at": "2026-06-09T13:36:22.731020+03:00" +} +``` + +--- + +## Observations + +### Create an Observation + +``` +POST /api/v1/external/observations/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital` | Yes | string | Hospital name | +| `description` | Yes | string | Observation description | +| `title` | No | string | Title (max 300 chars) | +| `severity` | No | string | One of: `low`, `medium`, `high`, `critical` (default: `medium`) | +| `category` | No | UUID | Observation category UUID | +| `location_text` | No | string | Free-text location description | +| `incident_datetime` | No | datetime | When the incident occurred | +| `contact_name` | No | string | Reporter name (anonymous supported) | +| `contact_phone` | No | string | Reporter phone | +| `contact_email` | No | string | Reporter email | +| `reporter_staff_id` | No | string | Staff ID of reporter | +| `patient_file_number` | No | string | Patient file number | + +**Response (201):** + +```json +{ + "success": true, + "reference_number": "OBS-O8TCIC", + "status": "new" +} +``` + +### List / Retrieve Observations + +``` +GET /api/v1/external/observations/list/ +GET /api/v1/external/observations// +``` + +**Retrieve Response:** + +```json +{ + "id": "uuid", + "reference_number": "OBS-O8TCIC", + "title": "", + "description": "Wet floor near the main entrance, no warning sign posted.", + "severity": "medium", + "status": "new", + "location_text": "Main entrance lobby", + "incident_datetime": "2026-06-09T13:36:23.542046+03:00", + "contact_name": "Anonymous Reporter", + "contact_phone": "", + "contact_email": "", + "reporter_staff_id": "", + "patient_file_number": "", + "hospital_name": "Al Nuzha", + "category_name": null, + "created_at": "2026-06-09T13:36:23.540024+03:00", + "updated_at": "2026-06-09T13:36:23.540079+03:00" +} +``` + +--- + +## Appreciations + +### Create an Appreciation + +``` +POST /api/v1/external/appreciations/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital` | Yes | string | Hospital name | +| `message` | Yes | string | Appreciation message | +| `contact_name` | Yes | string | Submitter name | +| `contact_phone` | Yes | string | Submitter phone | + +**Response (201):** + +```json +{ + "success": true, + "reference_number": "APR-20260609-239270", + "status": "draft" +} +``` + +### List / Retrieve Appreciations + +``` +GET /api/v1/external/appreciations/list/ +GET /api/v1/external/appreciations// +``` + +**Retrieve Response:** + +```json +{ + "id": "uuid", + "reference_number": "APR-20260609-239270", + "message_en": "Dr. Ahmed was incredibly kind and thorough.", + "status": "draft", + "hospital_name": "Al Nuzha", + "is_anonymous": false, + "created_at": "2026-06-09T13:36:23.788028+03:00", + "updated_at": "2026-06-09T13:36:23.788077+03:00" +} +``` + +--- + +## Suggestions + +### Create a Suggestion + +``` +POST /api/v1/external/suggestions/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital` | Yes | string | Hospital name | +| `message` | Yes | string | Suggestion message | +| `contact_name` | Yes | string | Submitter name | +| `contact_phone` | Yes | string | Submitter phone | +| `title` | No | string | Title (auto-generated from message if omitted) | +| `category` | No | string | One of: `clinical_care`, `staff_service`, `facility`, `communication`, `appointment`, `billing`, `food_service`, `cleanliness`, `technology`, `general`, `other` | +| `rating` | No | integer | Rating 1-5 | + +**Response (201):** + +```json +{ + "success": true, + "reference_number": "SG-20260609-316316", + "status": "submitted" +} +``` + +### List / Retrieve Suggestions + +``` +GET /api/v1/external/suggestions/list/ +GET /api/v1/external/suggestions// +``` + +**Retrieve Response:** + +```json +{ + "id": "uuid", + "reference_number": "SG-20260609-316316", + "title": "Digital queue system", + "message": "Please implement a digital queue management system.", + "category": "technology", + "rating": null, + "status": "submitted", + "feedback_type": "suggestion", + "sentiment": "neutral", + "contact_name": "Suggestor", + "contact_phone": "+966504445566", + "hospital_name": "Al Nuzha", + "created_at": "2026-06-09T13:36:24.051998+03:00", + "updated_at": "2026-06-09T13:36:24.052047+03:00" +} +``` + +--- + +## Doctor Ratings + +### Submit a Doctor Rating + +Submit a patient rating for a doctor. The `doctor_id` must be an existing employee ID in the Staff table for the given hospital. + +``` +POST /api/v1/external/doctor-ratings/ +``` + +**Request Body:** + +| Field | Required | Type | Description | +|---|---|---|---| +| `hospital_id` | Yes | integer | Hospital ID | +| `doctor_id` | Yes | string | Doctor's employee ID (must exist in the hospital's Staff records) | +| `rating` | Yes | integer | Rating from 1 to 5 | +| `doctor_name` | No | string | Doctor name (stored for reference) | +| `feedback` | No | string | Patient feedback text | +| `rating_date` | No | date | Date of rating (`YYYY-MM-DD`, defaults to current date) | +| `patient_uhid` | No | string | Patient unique health ID | +| `patient_name` | No | string | Patient name | +| `patient_type` | No | string | One of: `IP`, `OP`, `ER`, `DC` | +| `department_name` | No | string | Department name | +| `admit_date` | No | date | Admission date (`YYYY-MM-DD`) | +| `discharge_date` | No | date | Discharge date (`YYYY-MM-DD`) | + +**Example:** + +```json +{ + "hospital_id": 1, + "doctor_id": "10738", + "doctor_name": "Dr. Omaymah Yaqoub", + "rating": 4, + "feedback": "Great doctor, very caring and attentive.", + "rating_date": "2026-06-10", + "patient_uhid": "UHID12345", + "patient_name": "Ahmed Ali", + "patient_type": "OP", + "department_name": "Internal Medicine" +} +``` + +**Response (201):** + +```json +{ + "success": true, + "rating_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "staff_id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "doctor_name": "OMAYMAH YAQOUB ELAMEIAN", + "rating": 4 +} +``` + +**Error Responses:** + +| HTTP Status | Condition | +|---|---| +| `400` | `doctor_id` not found in the hospital's Staff records | +| `400` | `rating` outside 1-5 range | +| `400` | `hospital_id` not found | +| `403` | Hospital does not match API key scope | + +**Error Example (doctor not found):** + +```json +{ + "doctor_id": [ + "Doctor with employee ID '99999' not found at this hospital." + ] +} +``` + +--- + +## Common Patterns + +### Location Hierarchy + +Locations follow a cascading hierarchy. Use lookup endpoints to populate dependent dropdowns: + +``` +1. GET /hospitals/ → pick hospital +2. GET /location-types/ → pick location type +3. GET /areas/?hospital=X → pick area (filtered by hospital) +4. GET /departments/?hospital=X → pick department +5. GET /sections/?hospital=X&department=Y → pick section +``` + +### Date Filtering + +All list endpoints support date range filtering: + +``` +GET /complaints/list/?created_from=2026-01-01&created_to=2026-06-30 +GET /complaints/list/?updated_from=2026-06-01T08:00:00&updated_to=2026-06-09T17:00:00 +``` + +- Date-only values (`YYYY-MM-DD`): `created_from` starts at 00:00, `created_to` extends to 23:59:59 +- Full datetime values (`YYYY-MM-DDTHH:MM:SS`) are also accepted + +### Pagination + +All list endpoints return paginated results: + +```json +{ + "count": 150, + "page": 1, + "page_size": 20, + "results": [...] +} +``` + +| Parameter | Default | Max | +|---|---|---| +| `page` | 1 | - | +| `page_size` | 20 | 100 | + +### Reference Number Formats + +| Entity | Prefix | Format | +|---|---|---| +| Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` | +| Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` | +| Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) | +| Appreciation | `APR-` | `APR-YYYYMMDD-XXXXXX` | +| Suggestion | `SG-` | `SG-YYYYMMDD-XXXXXX` | diff --git a/e2e/helpers/audit.ts b/e2e/helpers/audit.ts new file mode 100644 index 0000000..e6b8c0d --- /dev/null +++ b/e2e/helpers/audit.ts @@ -0,0 +1,156 @@ +import { Page, Request, Response, expect } from '@playwright/test'; +import { RoleAuthHelper, RoleName } from './helpers'; + +export const E2E_HOSPITAL_NAME = 'E2E Test Hospital'; +export const E2E_PASSWORD = process.env.E2E_PASSWORD || 'Dev@123456'; +export const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8000'; + +export type ObsStatus = 'PASS' | 'FAIL' | 'WARN' | 'INFO' | 'SKIP'; +export interface Observation { + module: string; + step: string; + role?: string; + status: ObsStatus; + detail: string; + url?: string; + http?: number; + ts: string; +} + +export const OBS: Observation[] = []; + +export function observe( + module: string, + step: string, + status: ObsStatus, + detail: string, + opts: { role?: string; url?: string; http?: number } = {} +) { + const o: Observation = { + module, + step, + status, + detail, + role: opts.role, + url: opts.url, + http: opts.http, + ts: new Date().toISOString(), + }; + OBS.push(o); + const tag = `[${status}] ${module}/${step}${opts.role ? ` (${opts.role})` : ''}: ${detail}`; + if (status === 'FAIL') console.log('\x1b[31m' + tag + '\x1b[0m'); + else if (status === 'WARN') console.log('\x1b[33m' + tag + '\x1b[0m'); + else if (status === 'PASS') console.log('\x1b[32m' + tag + '\x1b[0m'); + else console.log(tag); +} + +/** + * Attach console / pageerror / response observers to a page. + * Captures JS errors, console errors, and HTTP 4xx/5xx + Django tracebacks. + */ +export function attachObservers(page: Page, module: string, role?: string) { + // auto-dismiss any unexpected JS dialog so it can't block the run + page.on('dialog', (d) => { observe(module, 'dialog', 'WARN', `${d.type()}: ${d.message().slice(0, 120)}`, { role }); d.dismiss().catch(() => {}); }); + page.on('console', (msg) => { + if (msg.type() === 'error') { + observe(module, 'console-error', 'WARN', msg.text(), { role }); + } + }); + page.on('pageerror', (err) => { + observe(module, 'page-error', 'FAIL', `${err.name}: ${err.message}`, { role }); + }); + page.on('requestfailed', (req: Request) => { + observe(module, 'request-failed', 'WARN', `${req.method()} ${req.url()} - ${req.failure()?.errorText}`, { role }); + }); + page.on('response', async (resp: Response) => { + const status = resp.status(); + const url = resp.url(); + if (status >= 400) { + let bodySnippet = ''; + try { + const ct = resp.headers()['content-type'] || ''; + if (ct.includes('text') || ct.includes('html') || ct.includes('json')) { + const body = await resp.text(); + const tbMatch = body.match(/(?:Traceback[\s\S]{0,400}|Server Error \(500\)|OperationalError|DoesNotExist|TemplateSyntaxError)/); + bodySnippet = tbMatch ? ` | ${tbMatch[0].slice(0, 200).replace(/\s+/g, ' ')}` : body.slice(0, 160).replace(/\s+/g, ' '); + } + } catch { + /* ignore */ + } + observe(module, 'http-error', status >= 500 ? 'FAIL' : 'WARN', `${resp.request().method()} ${status} ${url}${bodySnippet}`, { + role, + url, + http: status, + }); + } + }); +} + +/** Detect a Django error/traceback page in the current body. */ +export async function bodyHasTraceback(page: Page): Promise { + const text = await page.textContent('body').catch(() => ''); + if (!text) return null; + const patterns = [ + /Server Error \(500\)/, + /Traceback \(most recent call last\)/, + /Exception Type:[\s\S]{0,80}/, + /DoesNotExist/, + /OperationalError/, + /TemplateSyntaxError/, + /Page not found \(404\)/, + ]; + for (const p of patterns) { + const m = text.match(p); + if (m) return m[0].slice(0, 120).replace(/\s+/g, ' '); + } + return null; +} + +/** + * Login as a role. If role is px_admin, also select E2E hospital via the + * hospital switcher (so all scoped views target E2E Test Hospital). + */ +export async function loginAndScope(page: Page, role: RoleName, module: string) { + const auth = new RoleAuthHelper(page); + await auth.login(role); + if (role === 'px_admin') { + // px_admin users may land on dashboard or select-hospital; switch to E2E + await page.goto('/core/select-hospital/').catch(() => {}); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + const e2eLink = page.locator(`a:has-text("${E2E_HOSPITAL_NAME}"), a[href*="select-hospital"] >> text="${E2E_HOSPITAL_NAME}"`).first(); + if (await e2eLink.count().then((c) => c > 0)) { + await e2eLink.click().catch(() => {}); + await page.waitForLoadState('domcontentloaded').catch(() => {}); + } else { + // fallback: search any link containing the hospital name + const any = page.locator(`text="${E2E_HOSPITAL_NAME}"`).first(); + if (await any.count().then((c) => c > 0)) await any.click().catch(() => {}); + } + observe(module, 'login-scope', 'INFO', `px_admin scoped to ${E2E_HOSPITAL_NAME}`, { role }); + } + return auth; +} + +/** Resolve E2E hospital UUID from the public hospitals API. */ +export async function getE2EHospitalId(page: Page): Promise { + const resp = await page.context().request.get(`${BASE_URL}/core/api/hospitals/`, { timeout: 10000 }); + const data = await resp.json(); + const h = (data.hospitals || []).find((x: { name: string }) => x.name === E2E_HOSPITAL_NAME); + return h ? h.id : ''; +} + +/** + * Select the E2E hospital in a - - {% for s in staff_list %} - - {% endfor %} - -
- -
- - -

{% trans "Auto-populated from staff selection" %}

-
- -
- - -
-
- -
- - - {% trans "Cancel" %} - -
-
-
- -{% endif %} - - -{% if can_send %} -
- {% csrf_token %} -
-
-
- -
-

{% trans "Send Appreciation" %}

-
-
-

{% trans "Configure recipients and customize the message before sending." %}

- -
-
+ {% if metadata %} +
+

+ + {% trans "Submitted By" %} +

+
+ {% if metadata.submitted_by_name %}
-

{% trans "Notify Department Manager" %}

-

{% trans "Send to the department manager" %}

+

{% trans "Name" %}

+

{{ metadata.submitted_by_name }}

- -
-
+ {% endif %} + {% if metadata.submitted_by_phone %}
-

{% trans "Notify Department" %}

-

{% trans "Send to entire department" %}

+

{% trans "Phone" %}

+

{{ metadata.submitted_by_phone }}

- + {% endif %} + {% if metadata.submitted_by_email %} +
+

{% trans "Email" %}

+

{{ metadata.submitted_by_email }}

+
+ {% endif %} + {% if metadata.staff_name_mentioned %} +
+

{% trans "Staff Mentioned" %}

+

{{ metadata.staff_name_mentioned }}

+
+ {% endif %}
-
+ + {% endif %} -
- - -

{% trans "Leave blank to send the original appreciation message only" %}

-
+ {% if appreciation.recipient %} +
+

+ + {% trans "Assigned To" %} +

+
+
+ +
+
+

{{ appreciation.get_recipient_name }}

+ {% if appreciation.department %} +

{{ appreciation.department.name }}

+ {% endif %} +
+
+
+ {% endif %} -
- - -

{% trans "Comma-separated email addresses" %}

-
+ {% if appreciation.status == 'sent' or appreciation.status == 'acknowledged' %} +
+

+ + {% trans "Sent" %} +

+
+
+

{% trans "Sent At" %}

+

{{ appreciation.sent_at|date:"Y-m-d H:i" }}

+
+
+

{% trans "To Manager" %}

+

+ {% if appreciation.send_to_manager %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} +

+
+
+

{% trans "To Department" %}

+

+ {% if appreciation.send_to_department %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} +

+
+
+ {% if appreciation.custom_message %} +
+

{% trans "Custom Message" %}

+

{{ appreciation.custom_message }}

+
+ {% endif %} + {% if appreciation.cc_list %} +
+

{% trans "CC" %}

+
+ {% for email in appreciation.cc_list %} + {{ email }} + {% endfor %} +
+
+ {% endif %} + {% if appreciation.acknowledged_at %} +
+
+ +

{% trans "Acknowledged on" %} {{ appreciation.acknowledged_at|date:"Y-m-d H:i" }}

+
+
+ {% endif %} +
+ {% endif %} +
-
-
+ +
+ {% if can_activate %} +
+

+ {% trans "Activate Appreciation" %} +

+ + {% csrf_token %} +

{% trans "Select the staff member and department, then activate to trigger AI analysis." %}

+
+
+ + +
+
+ + +
+
+ + +
+
+ - - {% trans "Cancel" %} - -
-
-
-
-{% endif %} + + + {% endif %} - -{% if appreciation.status == 'sent' or appreciation.status == 'acknowledged' %} -
-
-
- -
-

{% trans "Sent" %}

-
-
-
-
-

{% trans "Sent At" %}

-

{{ appreciation.sent_at|date:"Y-m-d H:i" }}

-
-
-

{% trans "To Manager" %}

-

- {% if appreciation.send_to_manager %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} -

-
-
-

{% trans "To Department" %}

-

- {% if appreciation.send_to_department %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} -

-
-
- {% if appreciation.custom_message %} -
-

{% trans "Custom Message" %}

-

{{ appreciation.custom_message }}

-
- {% endif %} - {% if appreciation.cc_list %} -
-

{% trans "CC" %}

-
- {% for email in appreciation.cc_list %} - {{ email }} - {% endfor %} -
-
- {% endif %} - {% if appreciation.acknowledged_at %} -
-
- -

{% trans "Acknowledged on" %} {{ appreciation.acknowledged_at|date:"Y-m-d H:i" }}

-
-
+ {% if can_send %} +
+

+ {% trans "Send Appreciation" %} +

+
+ {% csrf_token %} +

{% trans "Configure recipients and customize the message before sending." %}

+
+
+
+

{% trans "Notify Manager" %}

+
+ +
+
+
+

{% trans "Notify Dept" %}

+
+ +
+
+
+ + +
+
+ + +
+ +
+
{% endif %}
-
-{% endif %} -{% endblock %} + -{% block extra_js %} {% endblock %} diff --git a/templates/appreciation/leaderboard.html b/templates/appreciation/leaderboard.html index 090b3bd..31c34bc 100644 --- a/templates/appreciation/leaderboard.html +++ b/templates/appreciation/leaderboard.html @@ -3,235 +3,298 @@ {% block title %}{% trans "Appreciation Leaderboard" %} - {% endblock %} -{% block content %} -
- - +{% block extra_css %} + +{% endblock %} + +{% block content %} + + + + + - -
-
-
-
- - -
-
- - -
-
- - -
-
- - - - {% trans "Reset" %} - -
-
+ +
+
+
+
+

{% trans "Filters" %}

+
+
+
+ + +
+
+ + +
+
+ + +
+ + + {% trans "Reset" %} + +
+
+
- -
-
- {% if page_obj %} -
- - - - - - - - - - - - - - {% for item in page_obj %} - - - - - - - - - - {% endfor %} - -
Rank{% trans "Recipient" %}{% trans "Hospital" %}{% trans "Department" %}{% trans "Received" %}{% trans "Sent" %}{% trans "Hospital Rank" %}
- {% if forloop.counter <= 3 %} - - {% if forloop.counter == 1 %}{% endif %} - #{{ forloop.counter }} - - {% else %} - #{{ forloop.counter }} - {% endif %} - - - {{ item.get_recipient_name }} - {{ item.hospital.name }}{% if item.department %}{{ item.department.name }}{% else %}-{% endif %} - {{ item.received_count }} - - {{ item.sent_count }} - - {% if item.hospital_rank %} - {% if item.hospital_rank <= 3 %} - - #{{ item.hospital_rank }} - - {% else %} - - #{{ item.hospital_rank }} - - {% endif %} - {% else %} - - - {% endif %} -
-
- - - {% if page_obj.has_other_pages %} - + + {% if page_obj.has_other_pages %} +
+
+ + {% trans "Showing" %} {{ page_obj.start_index }}-{{ page_obj.end_index }} {% trans "of" %} {{ page_obj.paginator.count }} {% trans "entries" %} + +
+
+ {% if page_obj.has_previous %} + + + {% endif %} - {% else %} -
- -

{% trans "No appreciations found for this period" %}

-

{% trans "Try changing the filters or select a different time period" %}

-
- {% endif %} -
-
- -
-
-
-
- -
{% trans "Send Appreciation" %}
-

{% trans "Share your appreciation with colleagues" %}

- - {% trans "View All" %} + {% for num in page_obj.paginator.page_range %} + {% if page_obj.paginator.num_pages <= 7 or num == page_obj.number or num == 1 or num == page_obj.paginator.num_pages %} + + {{ num }} + {% elif num == page_obj.number|add:"-1" or num == page_obj.number|add:"1" %} + {% elif num == 2 and page_obj.number > 4 %} + ... + {% elif num == page_obj.paginator.num_pages|add:"-1" and page_obj.number < page_obj.paginator.num_pages|add:"-3" %} + ... + {% endif %} + {% endfor %} + + {% if page_obj.has_next %} + + + + {% endif %} +
+
+ {% endif %} + {% else %} +
+ +

{% trans "No appreciations found for this period" %}

+

{% trans "Try changing the filters or select a different time period" %}

+
+ {% endif %} +
+ + +
+ +
+
+
+ +
+
+

{% trans "Send Appreciation" %}

+

{% trans "Share your appreciation with colleagues" %}

-
-
-
- -
{% trans "View Badges" %}
-

{% trans "See your earned badges" %}

-
- {% trans "My Badges" %} - + + +
+
+
+ +
+
+

{% trans "View Badges" %}

+

{% trans "See your earned badges" %}

-
- {% endblock %} {% block extra_js %} {{ block.super }} {% endblock %} diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index a8b0c36..43080a1 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -1,6 +1,7 @@ {% extends 'layouts/base.html' %} {% load i18n %} {% load static %} +{% get_current_language as LANG %} {% block title %}{{ complaint.reference_number }} - PX360{% endblock %} @@ -80,11 +81,25 @@ {% elif complaint.status == 'resolved' %}bg-green-100 text-green-700 {% elif complaint.status == 'closed' %}bg-slate-100 text-slate-600 {% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700 - {% elif complaint.status == 'contacted' %}bg-purple-100 text-purple-700 - {% elif complaint.status == 'contacted_no_response' %}bg-slate-100 text-slate-600 + {% elif complaint.status == 'pending_external' %}bg-cyan-100 text-cyan-700 + {% elif complaint.status == 'ovr_pending' %}bg-purple-100 text-purple-700 {% else %}bg-slate-100 text-slate-600{% endif %}"> {{ complaint.get_status_display }} + {% if complaint.patient_contact_status == 'contacted' %} + + {% trans "Patient Contacted" %} + + {% elif complaint.patient_contact_status == 'contacted_no_response' %} + + {% trans "No Response" %} + + {% endif %} + {% if not complaint.sent_to_any_department %} + + {% trans "Not Sent to Dept" %} + + {% endif %}
{% if complaint.source_complaint.exists %} {% with sc=complaint.source_complaint.first %} @@ -120,18 +135,6 @@ {% endif %}

{{ complaint.title }}

- {% if complaint.ai_brief_en %} -
- - {{ complaint.ai_brief_en }} - - {% if complaint.ai_brief_ar %} - - {{ complaint.ai_brief_ar }} - - {% endif %} -
- {% endif %}
{% comment %} @@ -163,7 +166,7 @@ {% comment %} {% endcomment %} - + + @@ -217,41 +234,26 @@
- -
-
-

- - {% trans "Complaint Details" %} -

- {% if complaint.source %} - - {% trans "Source:" %} {{ complaint.source.get_localized_name }} - - {% endif %} -
- +
{% if complaint.description %} -
-

- "{{ complaint.description }}" -

+
+

"{{ complaint.description }}"

{% endif %} - -
-
+ +
+

{% trans "Location" %}

- {% if complaint.location %}{{ complaint.location.name_en }}{% else %}-{% endif %} + {% if complaint.legacy_location %}{{ complaint.legacy_location.name_en }}{% else %}-{% endif %}

- {% if complaint.main_section %} -

{{ complaint.main_section.name_en }}{% if complaint.subsection %} > {{ complaint.subsection.name_en }}{% endif %}

+ {% if complaint.legacy_main_section %} +

{{ complaint.legacy_main_section.name_en }}{% if complaint.legacy_subsection %} > {{ complaint.legacy_subsection.name_en }}{% endif %}

{% endif %}
-
+

{% trans "Severity" %}

-

-
-

{% trans "Date Created" %}

-

- {{ complaint.created_at|date:"d M Y, h:i A" }} +

+

{% trans "Classification" %}

+

+ {% if complaint.ai_brief_en %} + {% if LANG == 'ar' and complaint.ai_brief_ar %} + {{ complaint.ai_brief_ar }} + {% else %} + {{ complaint.ai_brief_en }} + {% endif %} + {% else %}-{% endif %}

-
-

{% trans "Response Deadline" %}

+
+

{% trans "Created" %}

+

{{ complaint.created_at|date:"d M Y, h:i A" }}

+
+
+

{% trans "Deadline" %}

{{ complaint.due_at|date:"d M Y, h:i A" }} {% if complaint.is_overdue %} @@ -284,39 +296,35 @@

+ {% if complaint.domain or complaint.category or complaint.subcategory_obj or complaint.classification_obj %} +
+ {% trans "Taxonomy" %}: + {% if complaint.domain %}{{ complaint.domain.get_localized_name }}{% endif %} + {% if complaint.category %}{{ complaint.category.get_localized_name }}{% endif %} + {% if complaint.subcategory_obj %}{{ complaint.subcategory_obj.get_localized_name }}{% endif %} + {% if complaint.classification_obj %}{{ complaint.classification_obj.get_localized_name }}{% endif %} +
+ {% endif %} + + {% if complaint.source %} +
+ {% trans "Source" %}: + {{ complaint.source.get_localized_name }} +
+ {% endif %} + {% if complaint.escalated_at %} -
+

{% trans "Escalated" %}

{% trans "Escalated on" %} {{ complaint.escalated_at|date:"d M Y, h:i A" }}

+
{% endif %} - {# OVR Section - show when activated or has pending OVR request #} - {% if complaint.status != 'closed' and complaint.status != 'cancelled' %} - {% if can_edit %} -
- {% csrf_token %} - -
- {% endif %} - {% endif %} - - {# OVR Pending Approval - Show Approve/Reject buttons for admins #} {% if complaint.status == 'ovr_pending' %} -
+
OVR {% trans "Pending Approval" %} @@ -342,72 +350,27 @@ {% endif %}
{% endif %} - + {% if complaint.expected_result %} -
-

{% trans "Expected Result" %}

+
+

{% trans "Expected Result" %}

{{ complaint.expected_result }}

{% endif %} -
- - -
-

- - {% trans "Classification" %} -

-
- {% if complaint.domain %} -
-

{% trans "Domain" %}

-

{{ complaint.domain.get_localized_name }}

-
- {% endif %} - {% if complaint.category %} -
-

{% trans "Category" %}

-

{{ complaint.category.get_localized_name }}

-
- {% endif %} - {% if complaint.subcategory_obj %} -
-

{% trans "Subcategory" %}

-

{{ complaint.subcategory_obj.get_localized_name }}

-
- {% endif %} - {% if complaint.classification_obj %} -
-

{% trans "Classification" %}

-

{{ complaint.classification_obj.get_localized_name }}

-
+ + {% if complaint.patient %} +
+ + {{ complaint.patient.get_full_name }} + | + {% trans "MRN:" %} {{ complaint.patient.mrn|default:"-" }} + {% if complaint.patient.phone %} + | + {{ complaint.patient.phone }} {% endif %}
+ {% endif %}
- - - {% if complaint.patient %} -
-

- - {% trans "Patient Information" %} -

-
-
-

{% trans "Name" %}

-

{{ complaint.patient.get_full_name }}

-
-
-

{% trans "MRN" %}

-

{{ complaint.patient.mrn|default:"-" }}

-
-
-

{% trans "Phone" %}

-

{{ complaint.patient.phone|default:"-" }}

-
-
-
- {% endif %}
@@ -459,6 +422,14 @@ + + + +
@@ -504,18 +475,41 @@ {% trans "Escalate" %} +
+ {% csrf_token %} + +
{% else %} - + + {% if current_user.is_px_admin or current_user.is_hospital_admin %} +
+ {% csrf_token %} + + +
+ {% else %}

{% trans "Activate this complaint to perform actions" %}

{% endif %} + {% endif %} {% elif complaint.status == 'resolved' or complaint.status == 'closed' %} {% if can_edit %}
@@ -555,7 +549,7 @@ - {% if can_edit and available_transitions %} + {% if can_edit and available_transitions and complaint.assigned_to == current_user %} {% if current_user.is_px_admin or current_user.is_hospital_admin %}

@@ -583,23 +577,18 @@

{% endif %} {% endif %} - - - {% include "partials/stage_timeline.html" with stage_timeline=stage_timeline %} - {% if show_delay_reason_closure or complaint.delay_reason_closure %} + {% if complaint.is_active_status and complaint.delay_reason_closure %}

{% trans "72h Closure Delay Reason" %}

- {% if complaint.delay_reason_closure %}

{{ complaint.get_delay_reason_closure_display }}

- {% endif %} - {% if can_edit and complaint.status != "closed" and complaint.status != "resolved" %} + {% if can_edit %} {% csrf_token %} - - {% for target in escalation_targets %} - + + -

{% trans "If not selected, will escalate to the staff's direct manager." %}

-
- - +
+

{% trans "Email Preview" %} ({% trans "editable" %})

+
+ + +
+
+ + +
-
@@ -845,7 +911,7 @@
-
@@ -764,16 +755,54 @@ document.addEventListener('DOMContentLoaded', function() { const hospitalSelect = document.getElementById('hospitalSelect'); const hospitalField = hospitalSelect || document.querySelector('input[name="hospital"]'); + const locationTypeSelect = document.getElementById('locationTypeSelect'); + const areaSelect = document.getElementById('areaSelect'); const departmentSelect = document.getElementById('departmentSelect'); + const sectionSelect = document.getElementById('sectionSelect'); const staffSelect = document.getElementById('staffSelect'); - const locationSelect = document.getElementById('locationSelect'); - const mainSectionSelect = document.getElementById('mainSectionSelect'); - const subsectionSelect = document.getElementById('subsectionSelect'); + const deptCategorySelect = document.getElementById('id_dept_category'); const complaintTypeCards = document.querySelectorAll('.type-card'); const complaintTypeInput = document.getElementById('complaintTypeInput'); - // Load departments on page load if hospital is already selected - function loadDepartments(hospitalId) { + function getHospitalId() { + if (hospitalSelect) return hospitalSelect.value; + if (hospitalField) return hospitalField.value; + return null; + } + + function loadAreas(hospitalId, locationType) { + if (!areaSelect) return; + areaSelect.innerHTML = ''; + if (!hospitalId) { + areaSelect.innerHTML = ''; + return; + } + let url = '/organizations/dropdowns/areas/?hospital=' + hospitalId; + if (locationType) url += '&location_type=' + locationType; + fetch(url) + .then(r => r.json()) + .then(data => { + areaSelect.innerHTML = ''; + data.forEach(a => { + const opt = document.createElement('option'); + opt.value = a.id; + opt.textContent = a.name_en; + areaSelect.appendChild(opt); + }); + }) + .catch(err => { + console.error('Error loading areas:', err); + areaSelect.innerHTML = ''; + }); + } + + if (locationTypeSelect) { + locationTypeSelect.addEventListener('change', function() { + loadAreas(getHospitalId(), this.value); + }); + } + + function loadDepartments(hospitalId, category) { if (!hospitalId || !departmentSelect) { if (departmentSelect) { departmentSelect.innerHTML = ''; @@ -783,16 +812,18 @@ document.addEventListener('DOMContentLoaded', function() { departmentSelect.innerHTML = ''; - fetch('/organizations/api/departments/?hospital=' + hospitalId) + var url = '/organizations/dropdowns/departments-by-category/?hospital=' + encodeURIComponent(hospitalId); + if (category) url += '&category=' + encodeURIComponent(category); + + fetch(url) .then(response => response.json()) .then(data => { - const results = data.results || data; departmentSelect.innerHTML = ''; - results.forEach(dept => { + (data || []).forEach(dept => { const option = document.createElement('option'); option.value = dept.id; - option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %}; + option.textContent = dept.name_en || dept.name; departmentSelect.appendChild(option); }); }) @@ -802,71 +833,61 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Location hierarchy cascading dropdowns - if (locationSelect) { - locationSelect.addEventListener('change', function () { - const locationId = this.value; - if (!locationId) { - if (mainSectionSelect) mainSectionSelect.innerHTML = ''; - if (subsectionSelect) subsectionSelect.innerHTML = ''; - return; - } - if (mainSectionSelect) { - mainSectionSelect.innerHTML = ''; - } - fetch('{% url "organizations:ajax_main_sections" %}?location_id=' + locationId) - .then(r => r.json()) - .then(data => { - const sections = data.sections || []; - if (mainSectionSelect) { - mainSectionSelect.innerHTML = ''; - sections.forEach(section => { - const opt = document.createElement('option'); - opt.value = section.id; - opt.textContent = {% if LANG == 'ar' %}section.name_ar || section.name{% else %}section.name{% endif %}; - mainSectionSelect.appendChild(opt); - }); - } - if (subsectionSelect) { - subsectionSelect.innerHTML = ''; - } - }) - .catch(err => { - console.error('Failed to load main sections:', err); - if (mainSectionSelect) mainSectionSelect.innerHTML = ''; - }); + // Department category → filter departments + if (deptCategorySelect) { + deptCategorySelect.addEventListener('change', function() { + loadDepartments(getHospitalId(), this.value); + if (sectionSelect) sectionSelect.innerHTML = ''; }); } - if (mainSectionSelect) { - mainSectionSelect.addEventListener('change', function () { - const locationId = locationSelect ? locationSelect.value : ''; - const mainSectionId = this.value; - if (!locationId || !mainSectionId) { - if (subsectionSelect) subsectionSelect.innerHTML = ''; + // Department → Section + Staff cascading dropdowns + if (departmentSelect) { + departmentSelect.addEventListener('change', function () { + const deptId = this.value; + if (!deptId) { + if (sectionSelect) sectionSelect.innerHTML = ''; + if (staffSelect) staffSelect.innerHTML = ''; return; } - if (subsectionSelect) { - subsectionSelect.innerHTML = ''; - } - fetch('{% url "organizations:ajax_subsections" %}?location_id=' + locationId + '&main_section_id=' + mainSectionId) - .then(r => r.json()) - .then(data => { - const subsections = data.subsections || []; - if (subsectionSelect) { - subsectionSelect.innerHTML = ''; - subsections.forEach(sub => { + // Load sections + if (sectionSelect) { + sectionSelect.innerHTML = ''; + fetch('/organizations/dropdowns/sections/' + deptId + '/') + .then(r => r.json()) + .then(data => { + sectionSelect.innerHTML = ''; + (data || []).forEach(sec => { const opt = document.createElement('option'); - opt.value = sub.internal_id || sub.id; - opt.textContent = {% if LANG == 'ar' %}sub.name_ar || sub.name{% else %}sub.name{% endif %}; - subsectionSelect.appendChild(opt); + opt.value = sec.id; + opt.textContent = sec.name_en || sec.name; + sectionSelect.appendChild(opt); }); - } - }) - .catch(err => { - console.error('Failed to load subsections:', err); - if (subsectionSelect) subsectionSelect.innerHTML = ''; - }); + }) + .catch(err => { + console.error('Failed to load sections:', err); + sectionSelect.innerHTML = ''; + }); + } + // Load staff + if (staffSelect) { + staffSelect.innerHTML = ''; + fetch('/organizations/dropdowns/staff-by-department/' + deptId + '/') + .then(r => r.json()) + .then(data => { + staffSelect.innerHTML = ''; + (data || []).forEach(s => { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.name + (s.employee_id ? ' (' + s.employee_id + ')' : ''); + staffSelect.appendChild(opt); + }); + }) + .catch(err => { + console.error('Failed to load staff:', err); + staffSelect.innerHTML = ''; + }); + } }); } diff --git a/templates/complaints/complaint_list.html b/templates/complaints/complaint_list.html index d20e2c9..0725df1 100644 --- a/templates/complaints/complaint_list.html +++ b/templates/complaints/complaint_list.html @@ -295,12 +295,16 @@ {% elif complaint.status == 'resolved' %}bg-green-100 text-green-700 {% elif complaint.status == 'closed' %}bg-slate-100 text-slate-600 {% elif complaint.status == 'cancelled' %}bg-red-100 text-red-700 - {% elif complaint.status == 'contacted' %}bg-purple-100 text-purple-700 - {% elif complaint.status == 'contacted_no_response' %}bg-slate-100 text-slate-600 {% elif complaint.status == 'pending_external' %}bg-cyan-100 text-cyan-700 + {% elif complaint.status == 'ovr_pending' %}bg-purple-100 text-purple-700 {% else %}bg-slate-100 text-slate-600{% endif %}"> {{ complaint.get_status_display }} + {% if complaint.patient_contact_status == 'contacted' %} + {% trans "Contacted" %} + {% elif complaint.patient_contact_status == 'contacted_no_response' %} + {% trans "No Response" %} + {% endif %} {% if complaint.due_at and complaint.status != 'resolved' and complaint.status != 'closed' and complaint.status != 'cancelled' %} @@ -360,72 +364,7 @@ - - {% if complaints.has_other_pages %} -
-
- - {% trans "Showing" %} {{ complaints.start_index }}-{{ complaints.end_index }} {% trans "of" %} {{ complaints.paginator.count }} {% trans "entries" %} - - -
- {% for key, value in request.GET.items %} - {% if key != 'page_size' and key != 'page' %} - - {% endif %} - {% endfor %} - - -
-
-
- {% if complaints.has_previous %} - - - - {% else %} - - - - {% endif %} - - {% for num in complaints.paginator.page_range %} - {% if num == complaints.number %} - {{ num }} - {% elif num > complaints.number|add:'-3' and num < complaints.number|add:'3' %} - - {{ num }} - - {% elif num == 1 or num == complaints.paginator.num_pages %} - - {{ num }} - - {% elif num == complaints.number|add:'-3' or num == complaints.number|add:'3' %} - ... - {% endif %} - {% endfor %} - - {% if complaints.has_next %} - - - - {% else %} - - - - {% endif %} -
-
- {% endif %} + {% include "partials/pagination.html" %}
diff --git a/templates/complaints/complaint_pdf.html b/templates/complaints/complaint_pdf.html index 39dac2b..525dd8b 100644 --- a/templates/complaints/complaint_pdf.html +++ b/templates/complaints/complaint_pdf.html @@ -526,9 +526,9 @@
{% trans "Location" %}
- {% if complaint.location %}{{ complaint.location.name }}{% endif %} - {% if complaint.main_section %} / {{ complaint.main_section.name }}{% endif %} - {% if complaint.subsection %} / {{ complaint.subsection.name }}{% endif %} + {% if complaint.legacy_location %}{{ complaint.legacy_location.name }}{% endif %} + {% if complaint.legacy_main_section %} / {{ complaint.legacy_main_section.name }}{% endif %} + {% if complaint.legacy_subsection %} / {{ complaint.legacy_subsection.name }}{% endif %}
diff --git a/templates/complaints/complaint_summary_pdf.html b/templates/complaints/complaint_summary_pdf.html new file mode 100644 index 0000000..8c5a950 --- /dev/null +++ b/templates/complaints/complaint_summary_pdf.html @@ -0,0 +1,313 @@ + + + + + تقرير الشكوى - {{ complaint.reference_number }} + + + + + +
+ {% include "complaints/partials/pdf_letterhead_header.html" %} +
+ +
+
نموذج الشكوى
+ +
بيانات المشفى
+
+
رقم الملف
+
{{ complaint.reference_number|default:"—" }}
+
+
+
الاسم
+
{{ complainant_name|default:"—" }}
+
+
+
جهة الادعاء
+
{{ source_name|default:"—" }}
+
+
+
تاريخ تقديم الشكوى
+
{{ submission_date }}
+
+
+
تاريخ الحادثة
+
{{ incident_date }}
+
+
+
رقم الحالة
+
{{ complaint.reference_number|default:"—" }}
+
+ +
بيانات المشفى
+
+
القسم
+
{{ department_name|default:"—" }}
+
+
+
اسم الموظف
+
{{ accused_staff_name|default:"—" }}
+
+
+
الوظيفة
+
{{ accused_staff_title|default:"—" }}
+
+
+
تاريخ إرسال الشكوى
+
{{ sent_to_dept_date }}
+
+ +
مختصر الشكوى
+
+ {{ content_summary }} +
+ +
+
قسم علاقات المرضى
+
+
التوقيع والختم
+
+
+ + {% include "complaints/partials/pdf_letterhead_footer.html" %} +
+ + +
+ {% include "complaints/partials/pdf_letterhead_header.html" %} +
+ +
+
نموذج رد الشكوى
+ +
بيانات رد الشكوى
+
+
اسم الموظف
+
{{ accused_staff_name|default:"—" }}
+
+
+
الوظيفة
+
{{ accused_staff_title|default:"—" }}
+
+
+
القسم
+
{{ department_name|default:"—" }}
+
+
+
تاريخ رد الشكوى
+
{{ response_date }}
+
+
+
تاريخ إرسال الشكوى
+
{{ sent_to_dept_date }}
+
+ +
مختصر الرد
+
+ {% if dept_response_summary %} + {{ dept_response_summary }} + {% else %} + لم يتم تسجيل رد من القسم بعد. + {% endif %} +
+ +
+
قسم علاقات المرضى
+
+
التوقيع والختم
+
+
+ + {% include "complaints/partials/pdf_letterhead_footer.html" %} +
+ + + diff --git a/templates/complaints/explanation_already_submitted.html b/templates/complaints/explanation_already_submitted.html index 6a7d4b7..d52d774 100644 --- a/templates/complaints/explanation_already_submitted.html +++ b/templates/complaints/explanation_already_submitted.html @@ -7,9 +7,17 @@ {% trans "Already Submitted" %} - PX360 + diff --git a/templates/complaints/inquiry_explanation_form.html b/templates/complaints/inquiry_explanation_form.html index 530f162..6f90ddd 100644 --- a/templates/complaints/inquiry_explanation_form.html +++ b/templates/complaints/inquiry_explanation_form.html @@ -7,9 +7,17 @@ {% trans "Submit Response" %} - PX360 + diff --git a/templates/complaints/inquiry_form.html b/templates/complaints/inquiry_form.html index d7bd035..9de4710 100644 --- a/templates/complaints/inquiry_form.html +++ b/templates/complaints/inquiry_form.html @@ -113,6 +113,39 @@ {{ form.hospital }} {% endif %} +
+ + {{ form.location_type }} + {% for error in form.location_type.errors %} +

+ {{ error }} +

+ {% endfor %} +
+ +
+ + {{ form.area }} + {% for error in form.area.errors %} +

+ {{ error }} +

+ {% endfor %} +
+ +
+ + +
+
{{ form.department }} @@ -124,9 +157,9 @@
- - {{ form.source }} - {% for error in form.source.errors %} + + {{ form.section }} + {% for error in form.section.errors %}

{{ error }}

@@ -216,38 +249,6 @@
-
-
- - {{ form.location }} - {% for error in form.location.errors %} -

- {{ error }} -

- {% endfor %} -
- -
- - {{ form.main_section }} - {% for error in form.main_section.errors %} -

- {{ error }} -

- {% endfor %} -
- -
- - {{ form.subsection }} - {% for error in form.subsection.errors %} -

- {{ error }} -

- {% endfor %} -
-
-
+ {% endif %} +
+ {% else %} +
+
+ + {% trans "Pending Review" %} + {{ dept.response_submitted_at|date:"M d, Y H:i" }} +
+ {% if dept.response_notes_en %} +

{{ dept.response_notes_en }}

{% endif %}
{% endif %} + {% endif %}
- {% if can_edit %} + {% if can_edit and complaint.is_active_status %}
{% if not dept.response_submitted %} -
- {% csrf_token %} - -
+ {% endif %}
@@ -110,7 +191,7 @@ {% endif %} - {% if can_edit and explanation %} + {% if can_edit and complaint.is_active_status and explanation %}
{% csrf_token %} @@ -30,7 +30,7 @@ {% if explanations %}
- {% if can_edit %} + {% if can_edit and complaint.is_active_status %} @@ -89,6 +89,24 @@ {% if exp.is_used and exp.explanation %} + {% with linked_dept=exp.linked_involved_department %} + {% if linked_dept and linked_dept.manager_review_status == 'pending' %} +
+
+ + {% trans "Pending Manager Review" %} +
+

{% trans "Awaiting department manager approval." %}

+
+ {% elif linked_dept and linked_dept.manager_review_status == 'rejected' %} +
+
+ + {% trans "Rejected by Manager" %} +
+

{% trans "Champion needs to re-submit." %}

+
+ {% else %}

{{ exp.explanation }}

{% if exp.attachment_count > 0 %} @@ -99,9 +117,13 @@ {% endif %}
{% endif %} + {% endwith %} + {% endif %} - {% if can_edit and exp.is_used and not exp.escalated_to_manager %} + {% if can_edit and complaint.is_active_status and exp.is_used and not exp.escalated_to_manager %} + {% with linked_dept=exp.linked_involved_department %} + {% if not linked_dept or linked_dept.manager_review_status == 'approved' %}
{% if exp.acceptance_status == 'pending' %}
@@ -131,9 +153,11 @@ {% endif %}
{% endif %} + {% endwith %} + {% endif %} - {% if can_edit and not exp.is_used %} + {% if can_edit and complaint.is_active_status and not exp.is_used %}
{% if not exp.reminder_sent_at %} @@ -170,7 +194,7 @@

{% trans "No explanation requests sent yet" %}

- {% if can_edit %} + {% if can_edit and complaint.is_active_status %}
{% trans "Send to Department" %} @@ -180,7 +204,7 @@ - diff --git a/templates/complaints/partials/resolution_panel.html b/templates/complaints/partials/resolution_panel.html index 99e497d..8cced6d 100644 --- a/templates/complaints/partials/resolution_panel.html +++ b/templates/complaints/partials/resolution_panel.html @@ -1,6 +1,7 @@ {% load i18n %}

{% trans "Resolution" %}

+ {% if complaint.status == 'resolved' or complaint.status == 'closed' %}
@@ -182,12 +183,11 @@
-

{% trans "Who was in wrong / who was in right?" %}

+

{% trans "who was in right?" %}

diff --git a/templates/complaints/partials/staff_panel.html b/templates/complaints/partials/staff_panel.html index 558eeb0..e6bdb2c 100644 --- a/templates/complaints/partials/staff_panel.html +++ b/templates/complaints/partials/staff_panel.html @@ -2,7 +2,7 @@

{% trans "Involved Staff" %}

- {% if can_edit %} + {% if can_edit and complaint.is_active_status %} {% trans "Add Staff" %} @@ -61,7 +61,7 @@ {% endif %}
- {% if can_edit %} + {% if can_edit and complaint.is_active_status %}
{% if not staff_inv.explanation_received %} @@ -92,7 +92,7 @@

{% trans "No staff members involved yet" %}

- {% if can_edit %} + {% if can_edit and complaint.is_active_status %}
{% trans "Add First Staff" %} diff --git a/templates/complaints/partials/timeline_panel.html b/templates/complaints/partials/timeline_panel.html index 9435ce0..311e3f4 100644 --- a/templates/complaints/partials/timeline_panel.html +++ b/templates/complaints/partials/timeline_panel.html @@ -1,32 +1,45 @@ {% load i18n %}
-

{% trans "Activity Timeline" %}

- - {% if timeline %} -
- {% for update in timeline %} -
-
-
-
- - {{ update.get_update_type_display }} - - {% if update.created_by %} - {{ update.created_by.get_full_name }} - {% endif %} -
- {{ update.created_at|date:"M d, Y H:i" }} -
-

{{ update.message }}

-
-
- {% endfor %} -
- {% else %} +

{% trans "Timeline" %}

+ + {% if not stage_timeline.stages %}

{% trans "No activity recorded yet" %}

+ {% else %} +
+ {% for stage in stage_timeline.stages %} +
+
+ +
+
+
+

{{ stage.label }}

+

{{ stage.timestamp|date:"d M Y, h:i A" }}

+ {% if stage.performed_by %} +

+ {{ stage.performed_by }} +

+ {% endif %} +
+ {% if stage.duration_from_prev %} + + {{ stage.duration_from_prev }} + + {% endif %} +
+
+ {% endfor %} +
+ {% if stage_timeline.total_time %} +
+ {% trans "Total Time" %} + + {{ stage_timeline.total_time }} + +
+ {% endif %} {% endif %}
diff --git a/templates/complaints/patient_complaint_visit_form.html b/templates/complaints/patient_complaint_visit_form.html index 1c6ec80..8e8a7e9 100644 --- a/templates/complaints/patient_complaint_visit_form.html +++ b/templates/complaints/patient_complaint_visit_form.html @@ -38,7 +38,7 @@ transition: all 0.2s; background: white; color: #1e293b; - font-family: 'Inter', sans-serif; + font-family: 'Inter', 'Noto Kufi Arabic', sans-serif; resize: none; } .form-textarea:focus { diff --git a/templates/complaints/public_complaint_form.html b/templates/complaints/public_complaint_form.html index 8b9703b..856d057 100644 --- a/templates/complaints/public_complaint_form.html +++ b/templates/complaints/public_complaint_form.html @@ -5,146 +5,6 @@ {% block extra_css %} {% endblock %} {% block content %} -
-
-
-
- {% trans "Suggestions" %} - - {% trans "Detail" %} -
-

{% trans "Suggestion Detail" %}

-
+
+
+ {% trans "Suggestions" %} + + {{ feedback.title }} + + {{ feedback.get_status_display }} + + + {{ feedback.get_priority_display }} + + {% if feedback.is_featured %} + {% trans "FEATURED" %} + {% endif %} +
+
+

{% trans "Suggestion Detail" %}

{% if can_edit %} - + {% trans "Edit" %} {% endif %} - - {% trans "Back" %} -
-
+ -
-
+ -
-
-
-
- -
-
{{ feedback.title }}
-
-
- - {{ feedback.get_feedback_type_display }} - - - {{ feedback.get_status_display }} - - - {{ feedback.get_priority_display }} - - {% if feedback.is_featured %} - {% trans "FEATURED" %} - {% endif %} - {% if feedback.is_public %} - {% trans "PUBLIC" %} - {% endif %} -
-
-
+
+
+ +
+
+

+ + {% trans "Suggestion Details" %} +

{{ feedback.message }}

@@ -132,13 +97,13 @@
- {% if feedback.location %} + {% if feedback.legacy_location %}
{% trans "Location" %} - {{ feedback.location.name }} - {% if feedback.main_section %} {{ feedback.main_section.name }}{% endif %} - {% if feedback.subsection %} {{ feedback.subsection.name }}{% endif %} + {{ feedback.legacy_location.name }} + {% if feedback.legacy_main_section %} {{ feedback.legacy_main_section.name }}{% endif %} + {% if feedback.legacy_subsection %} {{ feedback.legacy_subsection.name }}{% endif %}
{% else %} @@ -245,18 +210,16 @@ ID: {{ feedback.id|slice:":8" }}
-
+
{% if feedback.has_ai_analysis %} -
-
-
+ -
{% trans "AI Analysis" %}
-
-
+ {% trans "AI Analysis" %} +

{{ feedback.ai_short_description_en }}

@@ -317,26 +280,23 @@
{% endif %} -
+
{% endif %} - +
-
+
{% if can_edit %} -
-
-
- -
-
{% trans "Actions" %}
-
-
+
+

{% trans "Quick Actions" %}

+
{% csrf_token %} - + {% for user in assignable_users %} @@ -367,7 +327,7 @@
- + @@ -379,50 +339,43 @@ {% trans "Create QI Project" %}
- -
- - {% comment %} - {% csrf_token %} - - - - - - {% endcomment %} - -
- - {% comment %}
-
- {% csrf_token %} - -
-
- {% csrf_token %} - -
-
{% endcomment %}
-
+ {% endif %} - +
+

+ {% trans "Type" %} +

+ + {{ feedback.get_feedback_type_display }} + + {% if feedback.is_public %} + {% trans "PUBLIC" %} + {% endif %} +
-
+ + + {% endblock %} diff --git a/templates/feedback/feedback_form.html b/templates/feedback/feedback_form.html index de5b262..c839b6d 100644 --- a/templates/feedback/feedback_form.html +++ b/templates/feedback/feedback_form.html @@ -108,30 +108,6 @@
-
- - -
- - - - -
+
+
+ +
-
- - +
+ +
-

+

- {% trans "At least one language is required. This response will be recorded as the department's official response." %} + {% trans "At least one language is required." %}

+
-
- - - {% trans "Cancel" %} - -
+
+ + + {% trans "Cancel" %} +
diff --git a/templates/observations/observation_detail.html b/templates/observations/observation_detail.html index ecbe436..591ff28 100644 --- a/templates/observations/observation_detail.html +++ b/templates/observations/observation_detail.html @@ -6,580 +6,625 @@ {% block extra_css %} {% endblock %} {% block content %} - - + - -
-
-
-
- {{ observation.tracking_code }} - - {{ observation.get_status_display }} - - - {{ observation.get_severity_display }} - - {% if observation.is_anonymous %} - - {% trans "Anonymous" %} - - {% endif %} - {% if observation.is_overdue %} - - {% trans "Overdue" %} - - {% endif %} -
-

- {% if observation.title %} - {{ observation.title }} - {% else %} - {{ observation.description|truncatewords:10 }} - {% endif %} -

-
- -
- {% if can_convert and not observation.action_id %} - - {% trans "Convert to Action" %} - - {% endif %} - - {% trans "Initiate RCA" %} - - - {% trans "Create QI Project" %} - -
+
+
+ {% trans "Observations" %} + + {{ observation.tracking_code }} + + {{ observation.get_status_display }} + + + {{ observation.get_severity_display }} + + {% if observation.is_anonymous %} + + {% trans "Anonymous" %} + + {% endif %} + {% if observation.is_overdue %} + + {% trans "Overdue" %} + + {% endif %} + {% if not observation.sent_to_department %} + + {% trans "Not Sent to Dept" %} + + {% endif %}
-
+
+

+ {% if observation.title %}{{ observation.title }}{% else %}{{ observation.description|truncatewords:10 }}{% endif %} +

+
+ -
- -
- -
-

- - {% trans "Description" %} -

-
-

{{ observation.description }}

-
- {% if observation.description_en %} -
-

English

-

{{ observation.description_en }}

-
- {% endif %} -
+ - - {% include "observations/partials/ai_panel.html" %} +
+
- -
-

- - {% trans "Details" %} -

-
-
-

{% trans "Category" %}

-

{{ observation.category.get_localized_name|default:_("Not specified") }}

+
+
+
+

{{ observation.description }}

- {% if observation.sub_category %} -
-

{% trans "Sub-Category" %}

-

{{ observation.sub_category.get_localized_name }}

+ {% if observation.description_en %} +
+

{{ observation.description_en }}

{% endif %} + + {% if observation.emotion or observation.short_description_en or observation.suggested_actions or observation.suggested_action_en %} +
+
+ + {% trans "AI Analysis" %} +
+ {% if observation.emotion and observation.emotion != 'neutral' %} +
+ + {{ observation.emotion|title }} + + {% trans "Confidence" %}: {{ observation.emotion_confidence_percent|floatformat:0 }}% +
+ {% endif %} + {% if observation.short_description_en %} +

{{ observation.short_description_en }}

+ {% if observation.short_description_ar %} +

{{ observation.short_description_ar }}

+ {% endif %} + {% elif observation.suggested_action_en %} +
+

{{ observation.suggested_action_en }}

+ {% if observation.suggested_action_ar %} +

{{ observation.suggested_action_ar }}

+ {% endif %} +
+ {% elif observation.suggested_actions %} +
+ {% for action in observation.suggested_actions %} +
+ + {{ action.priority }} + +

{{ action.action_en }}

+
+ {% endfor %} +
+ {% endif %} +
+ {% endif %} + +
+
+

{% trans "Category" %}

+

{{ observation.category.get_localized_name|default:"-" }}

+
+
+

{% trans "Location" %}

+

+ {% if observation.legacy_location %}{{ observation.legacy_location.name_en }}{% elif observation.location_text %}{{ observation.location_text }}{% else %}-{% endif %} +

+ {% if observation.legacy_main_section %} +

{{ observation.legacy_main_section.name_en }}{% if observation.legacy_subsection %} > {{ observation.legacy_subsection.name_en }}{% endif %}

+ {% endif %} +
+
+

{% trans "Incident" %}

+

{{ observation.incident_datetime|date:"d M Y" }}

+
+
+

{% trans "Deadline" %}

+

+ {% if observation.due_at %}{{ observation.due_at|date:"d M Y" }}{% else %}-{% endif %} + {% if observation.is_overdue %}{% endif %} +

+
+
+ {% if observation.taxonomy_domain or observation.taxonomy_category or observation.taxonomy_subcategory or observation.taxonomy_classification %} -
-

{% trans "SHCT Taxonomy" %}

-

- {{ observation.taxonomy_domain.name_en|default:"" }} - {% if observation.taxonomy_category %} > {{ observation.taxonomy_category.name_en }}{% endif %} - {% if observation.taxonomy_subcategory %} > {{ observation.taxonomy_subcategory.name_en }}{% endif %} - {% if observation.taxonomy_classification %} > {{ observation.taxonomy_classification.name_en }}{% endif %} -

+
+ {% trans "Taxonomy" %}: + {% if observation.taxonomy_domain %}{{ observation.taxonomy_domain.name_en }}{% endif %} + {% if observation.taxonomy_category %}{{ observation.taxonomy_category.name_en }}{% endif %} + {% if observation.taxonomy_subcategory %}{{ observation.taxonomy_subcategory.name_en }}{% endif %} + {% if observation.taxonomy_classification %}{{ observation.taxonomy_classification.name_en }}{% endif %}
{% endif %} -
-

{% trans "Location" %}

-

- {% if observation.location %}{{ observation.location.name_en }}{% elif observation.location_text %}{{ observation.location_text }}{% else %}{% trans "Not specified" %}{% endif %} -

-
- {% if observation.main_section %} -
-

{% trans "Section" %}

-

{{ observation.main_section.name_en }}

-
- {% endif %} - {% if observation.subsection %} -
-

{% trans "Subsection" %}

-

{{ observation.subsection.name_en }}

-
- {% endif %} -
-

{% trans "Incident Date/Time" %}

-

{{ observation.incident_datetime|date:"M d, Y H:i" }}

-
-
-

{% trans "Submitted" %}

-

{{ observation.created_at|date:"M d, Y H:i" }}

-
-
-

{% trans "Last Updated" %}

-

{{ observation.updated_at|date:"M d, Y H:i" }}

-
- {% if observation.due_at %} -
-

{% trans "Response Deadline" %}

-

- {{ observation.due_at|date:"M d, Y H:i" }} - {% if observation.is_overdue %}{% endif %} -

-
- {% endif %} - {% if observation.triaged_at %} -
-

{% trans "Triaged" %}

-

- {{ observation.triaged_at|date:"M d, Y H:i" }} - {% if observation.triaged_by %}({{ observation.triaged_by.get_full_name }}){% endif %} -

-
- {% endif %} -
-
- -
-

- - {% trans "Reporter Information" %} -

- {% if observation.is_anonymous %} -
-
- +
+ {% if observation.is_anonymous %} +
+ + {% trans "Anonymous submission" %} +
+ {% else %} +
+ + {% if observation.reporter_name %} + {{ observation.reporter_name }} + {% endif %} + {% if observation.reporter_phone %} + | + {{ observation.reporter_phone }} + {% endif %} + {% if observation.reporter_email %} + | + {{ observation.reporter_email }} + {% endif %} + {% if not observation.reporter_name and not observation.reporter_phone and not observation.reporter_email %} + {% trans "Reporter info not available" %} + {% endif %} +
+ {% endif %}
-

{% trans "This observation was submitted anonymously" %}

-
- {% else %} -
- {% if observation.reporter_staff_id %} -
-

{% trans "Staff ID" %}

-

{{ observation.reporter_staff_id }}

-
- {% endif %} - {% if observation.reporter_name %} -
-

{% trans "Name" %}

-

{{ observation.reporter_name }}

-
- {% endif %} - {% if observation.reporter_phone %} -
-

{% trans "Phone" %}

-

{{ observation.reporter_phone }}

-
- {% endif %} - {% if observation.reporter_email %} -
-

{% trans "Email" %}

-

{{ observation.reporter_email }}

-
- {% endif %} -
- {% endif %} -
+
+
- - {% if attachments %} -
-

- - {% trans "Attachments" %} ({{ attachments.count }}) -

-
- {% for attachment in attachments %} -
-
- -
-
{{ attachment.filename }}
-
{{ attachment.file_type }} - {{ attachment.file_size|filesizeformat }}
+ + + + + -
- {% endif %} + {% endif %} + {% if observation.department_response_ar %} +
+

العربية

+
+

{{ observation.department_response_ar|linebreaks }}

+
+
+ {% endif %} + {% if observation.department_response_summary_en %} +
+
+ + {% trans "AI Summary" %} +
+

{{ observation.department_response_summary_en }}

+ {% if observation.department_response_summary_ar %} +

{{ observation.department_response_summary_ar }}

+ {% endif %} +
+ {% endif %} - -
-

- - {% trans "Timeline" %} -

- {% if timeline %} -
- {% for item in timeline %} -
-
-
+ {% if observation.dept_response_acceptance_status %} +
+
- - {% if item.type == 'status_change' %}{% trans "Status Changed" %} - {% elif item.type == 'note' %}{% trans "Note" %}{% endif %} - - {% if item.item.created_by %} - {% trans "by" %} {{ item.item.created_by.get_full_name }} - {% elif item.item.changed_by %} - {% trans "by" %} {{ item.item.changed_by.get_full_name }} + {% if observation.dept_response_acceptance_status == 'acceptable' %} + + {% trans "Accepted" %} + {% elif observation.dept_response_acceptance_status == 'not_acceptable' %} + + {% trans "Not Acceptable" %} + {% else %} + + {% trans "Pending Review" %} + {% endif %} + {% if observation.dept_response_accepted_by %} + by {{ observation.dept_response_accepted_by.get_full_name }} {% endif %}
- {{ item.created_at|date:"M d, Y H:i" }} -
- {% if item.type == 'status_change' %} -
- {% if item.item.from_status %} - {{ item.item.from_status }} - + {% if can_review_dept_response and observation.dept_response_acceptance_status == 'pending' %} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
{% endif %} - {{ item.item.to_status }}
- {% elif item.type == 'note' %} -

{{ item.item.note }}

- {% endif %} - {% if item.item.comment %} -

{{ item.item.comment }}

+ {% if observation.dept_response_acceptance_notes %} +

{{ observation.dept_response_acceptance_notes }}

{% endif %}
+ {% endif %} + {% else %} +
+
+

{% trans "Waiting for department response..." %}

+ {% if observation.dept_response_sla_due_at %} +

{% trans "Deadline:" %} {{ observation.dept_response_sla_due_at|date:"Y-m-d H:i" }}

+ {% endif %} +
+ {% if can_send_reminder %} +
+
+ {% csrf_token %} + + +
+ {% if observation.dept_response_reminder_sent_at %} +
+ {% csrf_token %} + + +
+ {% endif %} +
+ {% endif %} + {% if can_respond_to_department %} +
+ +
+ {% endif %} + {% endif %}
- {% endfor %} -
+
{% else %} -
- -

{% trans "No timeline entries yet" %}

-
+
+
+ +

{% trans "No department assigned to this observation" %}

+
+
{% endif %} - +
+ + + +
- -
- - {% include "partials/stage_timeline.html" with stage_timeline=stage_timeline %} - - -
-
-

- {% trans "Assignment" %} -

-
-
-
-
{% trans "Department" %}
-
{{ observation.assigned_department.name|default:_("Not assigned") }}
-
-
-
{% trans "Assigned To" %}
-
{{ observation.assigned_to.get_full_name|default:_("Not assigned") }}
+
+
+

{% trans "Quick Actions" %}

+
+ {% if observation.status == 'new' and not observation.activated_at %} + {% if can_convert %} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + + +
+ {% else %} +
+ +

{% trans "Activate this observation to perform actions" %}

+ {% endif %} + {% else %} + {% if can_convert %} + + {% endif %} + {% if can_convert and not observation.action_id %} + + + {% trans "Convert" %} + + {% endif %} + {% if can_send_to_department and not observation.department_responded_at %} + + {% endif %} + {% if can_triage and observation.status != 'closed' and observation.status != 'cancelled' and observation.status != 'rejected' %} + + {% endif %} + {% if can_triage %} + + + {% trans "RCA" %} + + {% endif %} + + + {% trans "QI Project" %} + + {% if observation.status == 'resolved' or observation.status == 'closed' %} + {% if can_triage %} +
+ {% csrf_token %} + + +
+ {% endif %} + {% endif %} + {% if can_delete %} +
+ {% csrf_token %} + +
+ {% endif %} + {% endif %}
- - {% if observation.person_noted or observation.department_noted or observation.communication_method or observation.communication_datetime or observation.patient_file_number %} -
-
-

- {% trans "Communication" %} -

-
-
- {% if observation.patient_file_number %} -
-
{% trans "File Number" %}
-
{{ observation.patient_file_number }}
+ {% if can_convert %} + + {% endif %} + +
+

+ {% trans "Assignment" %} +

+
    +
  • + {% trans "Department" %} + {{ observation.assigned_department.name|default:_("Not assigned") }} +
  • +
  • + {% trans "Assigned To" %} + {{ observation.assigned_to.get_full_name|default:_("Not assigned") }} +
  • +
+
+ + {% if observation.person_noted or observation.department_noted or observation.communication_method or observation.communication_datetime or observation.patient_file_number %} +
+

+ {% trans "Communication" %} +

+
    + {% if observation.patient_file_number %} +
  • + {% trans "File Number" %} + {{ observation.patient_file_number }} +
  • {% endif %} {% if observation.person_noted %} -
    -
    {% trans "Person Noted" %}
    -
    {{ observation.person_noted }}
    -
    +
  • + {% trans "Person Noted" %} + {{ observation.person_noted }} +
  • {% endif %} {% if observation.department_noted %} -
    -
    {% trans "Department Noted" %}
    -
    {{ observation.department_noted.name }}
    -
    +
  • + {% trans "Department Noted" %} + {{ observation.department_noted.name }} +
  • {% endif %} {% if observation.communication_method %} -
    -
    {% trans "Via" %}
    -
    {{ observation.communication_method }}
    -
    +
  • + {% trans "Via" %} + {{ observation.communication_method }} +
  • {% endif %} {% if observation.communication_datetime %} -
    -
    {% trans "Contacted At" %}
    -
    {{ observation.communication_datetime|date:"M d, Y H:i" }}
    -
    +
  • + {% trans "Contacted At" %} + {{ observation.communication_datetime|date:"M d, Y H:i" }} +
  • {% endif %} -
+
{% endif %} - - {% if observation.assigned_department %} -
-
-

- - {% blocktrans with dept=observation.assigned_department.get_localized_name|default:observation.assigned_department.name %}Response from {{ dept }}{% endblocktrans %} - {% if observation.department_responded_at %} - - - {% trans "Received" %} - - {% elif observation.dept_response_is_overdue %} - - - {% trans "OVERDUE" %} - - {% else %} - - - {% trans "Awaiting" %} - - {% endif %} -

-
-
- {% if observation.forwarded_to_dept_at %} -
- - - {% trans "Sent:" %} {{ observation.forwarded_to_dept_at|date:"Y-m-d H:i" }} - - {% if observation.dept_response_sla_due_at and not observation.department_responded_at %} - - - - {% trans "Deadline:" %} {{ observation.dept_response_sla_due_at|date:"Y-m-d H:i" }} - - {% endif %} -
- {% endif %} - - {% if observation.department_responded_at %} -
- - - {{ observation.department_responded_by.get_full_name|default:"-" }} - - - - - {{ observation.department_responded_at|date:"Y-m-d H:i" }} - -
- {% if observation.department_response_en %} -
-

English

-
-

{{ observation.department_response_en|linebreaks }}

-
-
- {% endif %} - {% if observation.department_response_ar %} -
-

العربية

-
-

{{ observation.department_response_ar|linebreaks }}

-
-
- {% endif %} - {% if observation.department_response_summary_en %} -
-
- - {% trans "AI Summary" %} -
-

{{ observation.department_response_summary_en }}

- {% if observation.department_response_summary_ar %} -

{{ observation.department_response_summary_ar }}

- {% endif %} -
- {% endif %} - - - {% if observation.dept_response_acceptance_status %} -
-
-
- {% if observation.dept_response_acceptance_status == 'acceptable' %} - - {% trans "Accepted" %} - {% elif observation.dept_response_acceptance_status == 'not_acceptable' %} - - {% trans "Not Acceptable" %} - {% else %} - - {% trans "Pending Review" %} - {% endif %} - {% if observation.dept_response_accepted_by %} - by {{ observation.dept_response_accepted_by.get_full_name }} - {% endif %} -
- {% if can_review_dept_response and observation.dept_response_acceptance_status == 'pending' %} -
-
- {% csrf_token %} - - -
-
- {% csrf_token %} - - -
-
- {% endif %} -
- {% if observation.dept_response_acceptance_notes %} -

{{ observation.dept_response_acceptance_notes }}

- {% endif %} -
- {% endif %} - - {% else %} -
-
-

{% trans "Waiting for department response..." %}

- {% if observation.dept_response_sla_due_at %} -

- {% trans "Deadline:" %} {{ observation.dept_response_sla_due_at|date:"Y-m-d H:i" }} -

- {% endif %} -
- {% if can_send_reminder %} -
-
- {% csrf_token %} - - -
- {% if observation.dept_response_reminder_sent_at %} -
- {% csrf_token %} - - -
- {% endif %} -
- {% endif %} - {% endif %} - {% if can_respond_to_department %} - - {% endif %} -
-
- {% endif %} - - {% if px_action %}

@@ -592,212 +637,55 @@

{% endif %} - - {% if linked_rcas %} -
-
-

- {% trans "Root Cause Analyses" %} -

-
- -
- {% endif %} - - {% if can_triage %} -
-
-

- {% trans "Triage" %} -

-
-
-
- {% csrf_token %} -
- - {{ triage_form.assigned_department }} -
-
- - {{ triage_form.assigned_to }} -
-
- - {{ triage_form.status }} -
-
- - {{ triage_form.note }} -
- -
-
-
- {% endif %} - - -
-
-

- {% trans "Add Note" %} -

-
-
-
- {% csrf_token %} -
- {{ note_form.note }} -
-
- -
- -
-
-
- - -
-
-

- {% trans "Quick Status Change" %} -

-
-
-
- {% csrf_token %} -
- {{ status_form.status }} -
-
- {{ status_form.comment }} -
- -
-
-
- - {% if observation.status == 'resolved' or observation.status == 'closed' %} - - {% if can_triage %} -
-
-

- {% trans "Reopen Observation" %} -

-
-
-
- {% csrf_token %} -
- - -
- -
-
-
- {% endif %} - {% endif %} - - {% if can_send_to_department and not observation.department_responded_at %} - -
-
- -
-
- {% endif %} - - {% if can_convert %} - -
-
-

- - {% if observation.assigned_to %}{% trans "Reassign" %}{% else %}{% trans "Assign" %}{% endif %} -

-
-
- - {% csrf_token %} -
- - -
- - -
-
- {% endif %} - - {% if can_delete %} -
-
-

- {% trans "Delete" %} -

-
-
-
- {% csrf_token %} - -
-
+
{% endif %}
-
+ + + + {% include "components/send_to_modal.html" with users=assignable_users departments=departments %} +{% include "components/department_response_modal.html" %} {% endblock %} diff --git a/templates/observations/observation_list.html b/templates/observations/observation_list.html index f009316..ff1c31a 100644 --- a/templates/observations/observation_list.html +++ b/templates/observations/observation_list.html @@ -343,16 +343,10 @@ {{ observation.get_status_display }} @@ -398,45 +392,7 @@
- -{% if page_obj.has_other_pages %} -
-
- {% if page_obj.has_previous %} - - - - - - - {% endif %} - - {% for num in page_obj.paginator.page_range %} - {% if page_obj.number == num %} - {{ num }} - {% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %} - - {{ num }} - - {% endif %} - {% endfor %} - - {% if page_obj.has_next %} - - - - - - - {% endif %} -
-
-{% endif %} + {% include "partials/pagination.html" %} {% endblock %} {% block extra_js %} diff --git a/templates/observations/partials/observation_timeline_panel.html b/templates/observations/partials/observation_timeline_panel.html new file mode 100644 index 0000000..311e3f4 --- /dev/null +++ b/templates/observations/partials/observation_timeline_panel.html @@ -0,0 +1,45 @@ +{% load i18n %} +
+

{% trans "Timeline" %}

+ + {% if not stage_timeline.stages %} +
+ +

{% trans "No activity recorded yet" %}

+
+ {% else %} +
+ {% for stage in stage_timeline.stages %} +
+
+ +
+
+
+

{{ stage.label }}

+

{{ stage.timestamp|date:"d M Y, h:i A" }}

+ {% if stage.performed_by %} +

+ {{ stage.performed_by }} +

+ {% endif %} +
+ {% if stage.duration_from_prev %} + + {{ stage.duration_from_prev }} + + {% endif %} +
+
+ {% endfor %} +
+ {% if stage_timeline.total_time %} +
+ {% trans "Total Time" %} + + {{ stage_timeline.total_time }} + +
+ {% endif %} + {% endif %} +
diff --git a/templates/observations/public_new.html b/templates/observations/public_new.html index 773223c..4b3cb0c 100644 --- a/templates/observations/public_new.html +++ b/templates/observations/public_new.html @@ -79,6 +79,34 @@
+ +
+ + +
+ + + +
- {{ form.category }} +
- -
+ + + + @@ -110,33 +157,14 @@

{% trans "Please describe what you observed in detail." %}

- +
-
-
- - -
-
- - -
-
- - -
-
+ {{ form.location_text }}
@@ -216,10 +244,6 @@ {% block extra_js %} {% endblock %} diff --git a/templates/observations/public_success.html b/templates/observations/public_success.html index 58374fb..6a976e1 100644 --- a/templates/observations/public_success.html +++ b/templates/observations/public_success.html @@ -140,14 +140,10 @@

{% trans "Status" %}

{{ observation.get_status_display }} diff --git a/templates/observations/public_track.html b/templates/observations/public_track.html index f2a5325..5a56165 100644 --- a/templates/observations/public_track.html +++ b/templates/observations/public_track.html @@ -164,14 +164,10 @@ header.glass-card {

{{ observation.get_status_display }}

{{ observation.status }}
@@ -180,7 +176,7 @@ header.glass-card {
+ style="width: {% if observation.status == 'resolved' or observation.status == 'closed' %}100%{% elif observation.status == 'in_progress' %}50%{% else %}15%{% endif %}">
@@ -206,60 +202,47 @@ header.glass-card {
-
-

-
- -
- {% trans "Resolution Journey" %} -

- - {% if public_timeline %} -
- {% for item in public_timeline %} -
-
-
- -
+ {% if has_response %} + +
+
+

+
+
-
-
-

- {% if item.type == 'status_change' %} - {% trans "Status Updated" %} - {% if item.to_status %} - → {{ item.to_status }} - {% endif %} - {% elif item.type == 'note' %} - {% trans "Update Received" %} - {% else %} - {% trans "Final Resolution" %} - {% endif %} -

- + {% trans "Response" %} +

+
+ {% if response_en %} +
+
+ English
- {% if item.comment %} -
- {{ item.comment|linebreaks }} -
- {% endif %} +
{{ response_en }}
+ {% endif %} + {% if response_ar %} +
+
+ العربية +
+
{{ response_ar }}
+
+ {% endif %}
- {% endfor %}
- {% else %} +
+ {% else %} + +

{% trans "Your observation is being reviewed. Updates will appear here." %}

- {% endif %}
+ {% endif %}
diff --git a/templates/observations/response_already_submitted.html b/templates/observations/response_already_submitted.html new file mode 100644 index 0000000..ea771b2 --- /dev/null +++ b/templates/observations/response_already_submitted.html @@ -0,0 +1,18 @@ +{% extends "template.html" %} +{% load i18n %} + +{% block title %}{% trans "Response Already Submitted" %}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

{% trans "Response Already Submitted" %}

+

{% trans "This response link has already been used." %}

+
+
+
+{% endblock %} diff --git a/templates/observations/response_form_token.html b/templates/observations/response_form_token.html new file mode 100644 index 0000000..7b57cd5 --- /dev/null +++ b/templates/observations/response_form_token.html @@ -0,0 +1,52 @@ +{% extends "template.html" %} +{% load i18n %} + +{% block title %}{% trans "Respond to Observation" %} - {{ observation.tracking_code }}{% endblock %} + +{% block content %} +
+
+
+
+
+ +
+
+

{% trans "Department Response" %}

+

{{ observation.tracking_code }}

+
+
+ +
+

{% trans "Observation Details" %}

+

{{ observation.description }}

+ {% if observation.title %} +

{% trans "Title" %}: {{ observation.title }}

+ {% endif %} +

{% trans "Severity" %}: {{ observation.get_severity_display }}

+
+ + {% if error %} +
+

{{ error }}

+
+ {% endif %} + +
+ {% csrf_token %} +
+ + +
+
+ + +
+ +
+
+
+
+{% endblock %} diff --git a/templates/observations/response_success_token.html b/templates/observations/response_success_token.html new file mode 100644 index 0000000..b68adb3 --- /dev/null +++ b/templates/observations/response_success_token.html @@ -0,0 +1,19 @@ +{% extends "template.html" %} +{% load i18n %} + +{% block title %}{% trans "Response Submitted" %}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

{% trans "Response Submitted" %}

+

{% trans "Thank you for your response to observation" %} {{ observation.tracking_code }}.

+

{% trans "The PX team will review your response." %}

+
+
+
+{% endblock %} diff --git a/templates/observations/response_token_invalid.html b/templates/observations/response_token_invalid.html new file mode 100644 index 0000000..6904ce5 --- /dev/null +++ b/templates/observations/response_token_invalid.html @@ -0,0 +1,18 @@ +{% extends "template.html" %} +{% load i18n %} + +{% block title %}{% trans "Invalid Link" %}{% endblock %} + +{% block content %} +
+
+
+
+ +
+

{% trans "Invalid Link" %}

+

{% trans "This response link is invalid or has expired." %}

+
+
+
+{% endblock %} diff --git a/templates/organizations/department_complaint_detail.html b/templates/organizations/department_complaint_detail.html new file mode 100644 index 0000000..beaf197 --- /dev/null +++ b/templates/organizations/department_complaint_detail.html @@ -0,0 +1,167 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{{ complaint.reference_number }} - {{ department.get_localized_name }} - PX360{% endblock %} + +{% block content %} +
+ + +
+
+
+ +
+
+
+

{{ complaint.reference_number }}

+ + {{ complaint.get_status_display }} + + {% if complaint.severity %} + + {{ complaint.get_severity_display }} + + {% endif %} + {% if complaint.is_overdue %} + + {% trans "Overdue" %} + + {% endif %} +
+

{% trans "Complaint" %}

+
+
+
+ {% if can_respond and involved_dept %} + + {% endif %} + {% if can_manager_review and involved_dept %} + + {% trans "Review Response" %} + + {% endif %} + + {% trans "Back to complaints" %} + +
+
+ + {% if complaint.title %} +

{{ complaint.title }}

+ {% endif %} + +
+
+

+ {% trans "Description" %} +

+

{{ complaint.description|default:"—" }}

+
+ + {% if complaint.ai_brief_en %} +
+

+ {% trans "Short Description" %} +

+

{{ complaint.ai_brief_en }}

+
+ {% endif %} + +
+

+ {% trans "Details" %} +

+
+
+

{% trans "Department" %}

+

{{ complaint.department.get_localized_name|default:"—" }}

+
+
+

{% trans "Assigned To" %}

+

{{ complaint.assigned_to.get_full_name|default:"—" }}

+
+
+

{% trans "Patient MRN" %}

+

{{ complaint.patient.mrn|default:"—" }}

+
+ {% if staff_list %} +
+

{% trans "Staff Involved" %}

+ {% for s in staff_list %} +

{{ s }}

+ {% endfor %} +
+ {% endif %} + {% if taxonomy %} +
+

{% trans "Classification" %}

+

{{ taxonomy|join:" > " }}

+
+ {% endif %} + {% if location_str %} +
+

{% trans "Location" %}

+

{{ location_str }}

+
+ {% endif %} +
+

{% trans "Source" %}

+

{{ complaint.source.name_en|default:"—" }}

+
+
+

{% trans "Created" %}

+

{{ complaint.created_at|date:"Y-m-d H:i" }}

+
+ {% if complaint.due_at %} +
+

{% trans "SLA Deadline" %}

+

+ {{ complaint.due_at|date:"Y-m-d H:i" }} + {% if complaint.is_overdue %} ({% trans "Overdue" %}){% endif %} +

+
+ {% endif %} + {% if complaint.escalated_at %} +
+

{% trans "Escalated" %}

+

{{ complaint.escalated_at|date:"Y-m-d H:i" }}

+
+ {% endif %} +
+
+ + {% if complaint.expected_result %} +
+

+ {% trans "Expected Result" %} +

+

{{ complaint.expected_result }}

+
+ {% endif %} +
+
+ +{% include "components/department_response_modal.html" %} +{% endblock %} diff --git a/templates/organizations/department_complaints.html b/templates/organizations/department_complaints.html new file mode 100644 index 0000000..db377da --- /dev/null +++ b/templates/organizations/department_complaints.html @@ -0,0 +1,213 @@ +{% extends "layouts/base.html" %} +{% load i18n %} +{% load analytics_extras %} + +{% block title %}{{ department.get_localized_name }} - {% trans "Complaints" %} - PX360{% endblock %} + +{% block content %} +
+
+ {% trans "Departments" %} + + {{ department.get_localized_name }} + + {% trans "Complaints" %} +
+ +
+
+

{% trans "Department Complaints" %}

+

{{ department.get_localized_name }} · {{ department.hospital.name }}

+
+
+ +
+
+
+ + +
+ + + + + + {% if search_query or status_filter or severity_filter or date_from or date_to %} + + {% trans "Clear" %} + + {% endif %} +
+
+ + {% include "partials/list_stats_bar.html" %} + +
+ + + + + + + + + + + + + {% if can_respond %} + + {% endif %} + + + + {% for c in complaints %} + + + + + + + + + + + {% if can_respond %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
{% trans "Reference" %}{% trans "Classification" %}{% trans "Staff" %}{% trans "Patient" %}{% trans "Assigned To" %}{% trans "Status" %}{% trans "Severity" %}{% trans "SLA" %}{% trans "Created" %}{% trans "Action" %}
{{ c.reference_number }} +
+ {% if c.domain %}{{ c.domain.get_localized_name }}{% endif %} + {% if c.category %}{{ c.category.get_localized_name }}{% endif %} + {% if c.subcategory_obj %}{{ c.subcategory_obj.get_localized_name }}{% endif %} +
+
+ {% if c.involved_staff.all %} + {% for inv in c.involved_staff.all %} +
{{ inv.staff.get_localized_name }}
+ {% endfor %} + {% elif c.staff %} +
{{ c.staff.get_localized_name }}
+ {% else %} + - + {% endif %} +
{{ c.patient.mrn|default:"-" }}{{ c.assigned_to.get_full_name|default:"-" }} + + {{ c.get_status_display }} + + + + {{ c.get_severity_display }} + + + {% if c.due_at and c.status != 'resolved' and c.status != 'closed' %} + + {% elif c.is_overdue %} + {% trans "Overdue" %} + {% else %} + - + {% endif %} + {{ c.created_at|date:"Y-m-d" }} + {% with idept=complaint_dept_map|get_item:c.pk %} + {% if idept %} + {% if idept.response_submitted and idept.manager_review_status == 'pending' %} + + {% trans "Pending Manager Review" %} + + {% else %} + + {% endif %} + {% else %} + ✓ {% trans "Submitted" %} + {% endif %} + {% endwith %} +
{% trans "No complaints found" %}
+
+ + {% include "partials/pagination.html" %} +
+ +{% if can_respond %} +{% include "components/department_response_modal.html" %} +{% endif %} + + +{% endblock %} diff --git a/templates/organizations/department_detail.html b/templates/organizations/department_detail.html index c926e78..a365987 100644 --- a/templates/organizations/department_detail.html +++ b/templates/organizations/department_detail.html @@ -14,6 +14,7 @@ .stat-card { background: white; border-radius: 1rem; border: 2px solid #e2e8f0; padding: 1.25rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); transition: all 0.3s ease; + text-decoration: none; color: inherit; } .stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 15px rgba(0,0,0,0.1); border-color: #005696; } .tab-btn { padding: 0.75rem 1.5rem; font-size: 0.875rem; font-weight: 600; border-bottom: 3px solid transparent; transition: all 0.2s; } @@ -76,58 +77,6 @@ {% endif %}
-
-
-

{% trans "Manager" %}

- {% if department.manager %} -

{{ department.manager.get_full_name }}

- {% else %} -

{% trans "Not assigned" %}

- {% endif %} -
- {% if department.manager %} -
- -
- {% elif can_edit %} - - {% endif %} - {% if can_edit %} - - {% endif %} -
-
-
-

{% trans "Champion" %}

- {% if department.respondent %} -

{{ department.respondent.get_full_name }}

- {% else %} -

{% trans "Not set" %}

- {% endif %} -
- {% if department.respondent %} -
- -
- {% elif can_assign %} - - {% endif %} - {% if can_assign %} - - {% endif %} -
{% if can_edit %} @@ -148,7 +97,7 @@
- - - +
{% if pending_actions %} @@ -235,7 +184,21 @@ {% if action.type == 'complaint_department_response' %} + {% elif action.type == 'observation_response' %} + + {% elif action.type == 'inquiry_response' %} +
+ {% if pending_actions_has_more %} +
+ {% trans "Showing 10 of" %} {{ pending_actions_count }} — {% trans "respond to items to see more" %} +
+ {% endif %}
{% endif %} @@ -278,11 +246,17 @@ - {% if not user.is_department_respondent or user.is_px_admin or user.is_hospital_admin or user.is_department_manager %} + {% if can_manage_roles %} + {% endif %} + @@ -292,6 +266,12 @@ + + @@ -338,7 +318,27 @@
- {% if search_query or complaint_status_filter or inquiry_status_filter or observation_status_filter %} + + + {% if search_query or complaint_status_filter or inquiry_status_filter or observation_status_filter or suggestion_status_filter or appreciation_status_filter %} {% trans "Clear" %} @@ -349,7 +349,6 @@
- {% if not user.is_department_respondent or user.is_px_admin or user.is_hospital_admin or user.is_department_manager %}
@@ -365,7 +364,8 @@ {% for s in staff_list %} - +
@@ -409,8 +409,130 @@
+ + + {% if can_manage_roles %} +
+
+
+
+

+ {% trans "Department Roles" %} +

+

{% trans "Manage who receives complaints, inquiries, and observations for this department." %}

+
+
+
+ {% for role in role_data %} +
+
+
+
+ +
+
+

{{ role.label }}

+ {% if role.field == 'champion' %} + {% trans "Primary Contact" %} + {% endif %} +
+
+
+
+ {% if role.staff %} +
+
+ {{ role.staff.get_full_name|first|upper }} +
+
+

{{ role.staff.get_full_name }}

+

{{ role.staff.email|default:"No email" }}

+
+
+ {% else %} +
+ + {% trans "Not assigned" %} +
+ {% endif %} +
+ {% if can_manage_roles %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+
{% endif %} + +
+
+
+

{% trans "Department Sections" %}

+ + {% trans "Add Section" %} + +
+ + + + + + + + + + + + + + + {% for sec in org_sections %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
{% trans "Section Name" %}{% trans "Location" %}{% trans "Champion" %}{% trans "Supervisor" %}{% trans "Deputy" %}{% trans "Sub-Sections" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ sec.name_en }} + {% if sec.name_ar %}
{{ sec.name_ar }}{% endif %} +
{{ sec.location_type|default:"-" }}{% if sec.sub_location %} / {{ sec.sub_location }}{% endif %}{{ sec.champion.get_full_name|default:"-" }}{{ sec.supervisor.get_full_name|default:"-" }}{{ sec.deputy_supervisor.get_full_name|default:"-" }} + {{ sec.subsections.count }} + + {% if sec.status == 'active' %} + {% trans "Active" %} + {% else %} + {% trans "Inactive" %} + {% endif %} + +
+ + + +
+
{% trans "No sections in this department" %}
+
+
+
@@ -427,7 +549,7 @@ {% for c in complaints %} - +
{{ c.reference_number }}
@@ -468,31 +590,19 @@ {% endfor %}
+ {% if stats.total_complaints > 0 %} + + {% endif %}
- +
- {% if user.is_department_respondent and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager %} -
- {% for i in inquiries %} -
-

{{ i.message }}

- {% if i.status in 'open,in_progress' %} - - {% endif %} -
- {% empty %} -
- -

{% trans "No pending inquiries" %}

-
- {% endfor %} -
- {% else %} @@ -501,6 +611,7 @@ + @@ -520,13 +631,14 @@ {{ i.get_status_display }} + {% empty %} - + {% endfor %}
{% trans "Contact" %} {% trans "Assigned To" %} {% trans "Status" %}{% trans "Created" %} {% trans "Actions" %}
{{ i.created_at|date:"Y-m-d" }}
- + {% trans "View" %} {% if can_respond and i.status in 'open,in_progress' %} - + {% trans "Respond" %} {% endif %} @@ -534,10 +646,18 @@
{% trans "No inquiries for this department" %}
{% trans "No inquiries for this department" %}
+ {% if stats.total_inquiries > 0 %} + {% endif %}
@@ -552,11 +672,12 @@ {% trans "Assigned To" %} {% trans "Status" %} {% trans "Created" %} + {% trans "Actions" %} {% for o in observations %} - + {{ o.tracking_code }} {{ o.title|default:"-" }} @@ -575,9 +696,144 @@ {{ o.created_at|date:"Y-m-d" }} + +
+ + {% trans "View" %} + + {% if can_respond and not o.department_responded_at %} + + {% trans "Respond" %} + + {% endif %} +
+ {% empty %} - {% trans "No observations for this department" %} + {% trans "No observations for this department" %} + {% endfor %} + + + {% if stats.total_observations > 0 %} + + {% endif %} +
+ + +
+ + + + + + + + + + + + + + + {% for s in suggestions %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
{% trans "Reference" %}{% trans "Title" %}{% trans "Priority" %}{% trans "Category" %}{% trans "Contact" %}{% trans "Assigned To" %}{% trans "Status" %}{% trans "Created" %}
{{ s.pk|truncatechars:8 }}{{ s.title|default:"-" }} + + {{ s.get_priority_display }} + + {{ s.get_category_display|default:"-" }} + {% if s.is_anonymous %} + {% trans "Anonymous" %} + {% else %} + {{ s.contact_name|default:"-" }} + {% endif %} + {{ s.assigned_to.get_full_name|default:"-" }} + + {{ s.get_status_display }} + + {{ s.created_at|date:"Y-m-d" }}
{% trans "No suggestions for this department" %}
+
+ + +
+ + + + + + + + + + + + + + {% for a in appreciations %} + + + + + + + + + + {% empty %} + {% endfor %}
{% trans "Reference" %}{% trans "Category" %}{% trans "Sender" %}{% trans "Recipient" %}{% trans "Status" %}{% trans "Sent" %}{% trans "Created" %}
{{ a.pk|truncatechars:8 }} + {% if a.category %} + + {{ a.category.name_en }} + + {% else %} + - + {% endif %} + + {% if a.is_anonymous %} + {% trans "Anonymous" %} + {% elif a.sender %} + {{ a.sender.get_full_name }} + {% else %} + - + {% endif %} + {{ a.get_recipient_name|default:"-" }} + + {{ a.get_status_display }} + + {{ a.sent_at|date:"Y-m-d"|default:"-" }}{{ a.created_at|date:"Y-m-d" }}
{% trans "No appreciations for this department" %}
@@ -661,163 +917,132 @@
+ + +
+
+ + + + + + + + + + + + {% for item in standards_data %} + + + + + + + + {% empty %} + + {% endfor %} + +
{% trans "Code" %}{% trans "Title" %}{% trans "Activity Type" %}{% trans "Status" %}{% trans "Evidence" %}
+ + {{ item.standard.code }} + + + + {{ item.standard.title }} + + + {% if item.standard.activity_type %} + + {{ item.standard.activity_type.name }} + + {% else %} + - + {% endif %} + + {% if not item.standard.is_assessable or item.standard.is_heading %} + + {% trans "Informational" %} + + {% else %} + + + {% endif %} + + {% if not item.standard.is_assessable or item.standard.is_heading %} + - + {% else %} + + {% endif %} +
+ + {% trans "No standards for this department" %} +
+
+
- -
-
- - - - - - - - - - - - {% for item in standards_data %} - - - - - - - - {% empty %} - - {% endfor %} - -
{% trans "Code" %}{% trans "Title" %}{% trans "Activity Type" %}{% trans "Status" %}{% trans "Evidence" %}
- - {{ item.standard.code }} - - - - {{ item.standard.title }} - - - {% if item.standard.activity_type %} - - {{ item.standard.activity_type.name }} - - {% else %} - - - {% endif %} - - {% if not item.standard.is_assessable or item.standard.is_heading %} - - {% trans "Informational" %} - - {% else %} - - - {% endif %} - - {% if not item.standard.is_assessable or item.standard.is_heading %} - - - {% else %} - - {% endif %} -
- - {% trans "No standards for this department" %} -
-
-
- - - +{% endblock %} diff --git a/templates/organizations/orgsection_confirm_delete.html b/templates/organizations/orgsection_confirm_delete.html new file mode 100644 index 0000000..80a3e85 --- /dev/null +++ b/templates/organizations/orgsection_confirm_delete.html @@ -0,0 +1,56 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% trans "Delete Section" %} - PX360{% endblock %} + +{% block content %} +
+
+
+
+ +
+ +

{% trans "Delete Section" %}

+

+ {% blocktrans %}Are you sure you want to delete {{ section.name_en }}? This action cannot be undone.{% endblocktrans %} +

+ +
+
+
+ {% trans "Name" %} + {{ section.name_en }} +
+
+ {% trans "Code" %} + {{ section.code|default:"-" }} +
+
+ {% trans "Department" %} + {{ section.department.get_localized_name }} +
+
+ {% trans "Status" %} + + {% if section.status == 'active' %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %} + +
+
+
+ +
+ {% csrf_token %} +
+ + {% trans "Cancel" %} + + +
+
+
+
+
+{% endblock %} diff --git a/templates/organizations/orgsection_detail.html b/templates/organizations/orgsection_detail.html new file mode 100644 index 0000000..b0ea710 --- /dev/null +++ b/templates/organizations/orgsection_detail.html @@ -0,0 +1,191 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{{ section.name_en }} - {% trans "Org Section" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+
+ +
+
+

{{ section.name_en }}

+ {% if section.name_ar %} +

{{ section.name_ar }}

+ {% endif %} +
+
+
+ {% if section.code %} + {{ section.code }} + {% endif %} + {{ section.get_location_type_display }} + {% if section.sub_location %} + {{ section.sub_location }} + {% endif %} + {% if section.floor %} + {% trans "Floor" %} {{ section.floor }} + {% endif %} + {{ section.department.get_localized_name }} +
+
+ +
+
+ + +
+
+
+
+ +
+
+

{% trans "Sub-Sections" %}

+

{{ sub_sections_count }}

+
+
+
+
+ + +
+
+
+
+ +
+

{% trans "Sub-Sections" %}

+
+ + {% trans "Add Sub-Section" %} + +
+
+ + + + + + + + + + + + {% for sub in sub_sections %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name (EN)" %}{% trans "Name (AR)" %}{% trans "Code" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ sub.name_en }} + + {{ sub.name_ar|default:"-" }} + + {{ sub.code|default:"-" }} + + {% if sub.status == 'active' %} + {% trans "Active" %} + {% else %} + {% trans "Inactive" %} + {% endif %} + + +
+ +

{% trans "No sub-sections found" %}

+
+
+
+
+{% endblock %} diff --git a/templates/organizations/orgsection_form.html b/templates/organizations/orgsection_form.html new file mode 100644 index 0000000..7b1dde9 --- /dev/null +++ b/templates/organizations/orgsection_form.html @@ -0,0 +1,157 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% if section %}{% trans "Edit Section" %}{% else %}{% trans "Add Section" %}{% endif %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+ +

+ {% if section %}{% trans "Edit Section" %}{% else %}{% trans "Add Section" %}{% endif %} +

+

+ {% if section %}{% trans "Update section information" %}{% else %}{% trans "Create a new org section" %}{% endif %} +

+
+ +
+
+
+ {% csrf_token %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + {% trans "Cancel" %} + + +
+
+
+
+{% endblock %} diff --git a/templates/organizations/orgsection_list.html b/templates/organizations/orgsection_list.html new file mode 100644 index 0000000..1b42fe7 --- /dev/null +++ b/templates/organizations/orgsection_list.html @@ -0,0 +1,214 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% trans "Org Sections" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+

{% trans "Org Sections" %}

+

{% trans "Manage organization sections" %}

+
+ + {% trans "Add Section" %} + +
+
+ + +
+
+
+ +
+

{% trans "Filters" %}

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+
+
+ + +
+
+
+ +
+

{% trans "Sections List" %}

+
+
+ + + + + + + + + + + + + + + {% for section in sections %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name (EN)" %}{% trans "Name (AR)" %}{% trans "Code" %}{% trans "Department" %}{% trans "Location" %}{% trans "Champion" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ section.name_en }} + + {{ section.name_ar|default:"-" }} + + {{ section.code|default:"-" }} + + + {{ section.department.get_localized_name }} + + + {{ section.get_location_type_display|default:"-" }} + + {{ section.champion.get_full_name|default:"-" }} + + {% if section.status == 'active' %} + {% trans "Active" %} + {% else %} + {% trans "Inactive" %} + {% endif %} + + +
+ +

{% trans "No sections found" %}

+
+
+ + + {% if page_obj.has_other_pages %} +
+
+ {% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}{{ current }} of {{ total }}{% endblocktrans %} +
+
+ {% if page_obj.has_previous %} + + + + {% endif %} + + + {{ page_obj.number }} + + + {% if page_obj.has_next %} + + + + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/organizations/orgsubsection_confirm_delete.html b/templates/organizations/orgsubsection_confirm_delete.html new file mode 100644 index 0000000..addd574 --- /dev/null +++ b/templates/organizations/orgsubsection_confirm_delete.html @@ -0,0 +1,56 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% trans "Delete Sub-Section" %} - PX360{% endblock %} + +{% block content %} +
+
+
+
+ +
+ +

{% trans "Delete Sub-Section" %}

+

+ {% blocktrans %}Are you sure you want to delete {{ sub_section.name_en }}? This action cannot be undone.{% endblocktrans %} +

+ +
+
+
+ {% trans "Name" %} + {{ sub_section.name_en }} +
+
+ {% trans "Code" %} + {{ sub_section.code|default:"-" }} +
+
+ {% trans "Parent Section" %} + {{ sub_section.section.name_en }} +
+
+ {% trans "Status" %} + + {% if sub_section.status == 'active' %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %} + +
+
+
+ +
+ {% csrf_token %} +
+ + {% trans "Cancel" %} + + +
+
+
+
+
+{% endblock %} diff --git a/templates/organizations/orgsubsection_form.html b/templates/organizations/orgsubsection_form.html new file mode 100644 index 0000000..59cd29e --- /dev/null +++ b/templates/organizations/orgsubsection_form.html @@ -0,0 +1,167 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% if sub_section %}{% trans "Edit Sub-Section" %}{% else %}{% trans "Add Sub-Section" %}{% endif %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+ {% if sub_section %} + + {% trans "Back" %} + + {% elif preselected_section %} + + {% trans "Back" %} + + {% else %} + + {% trans "Back" %} + + {% endif %} +
+

+ {% if sub_section %}{% trans "Edit Sub-Section" %}{% else %}{% trans "Add Sub-Section" %}{% endif %} +

+

+ {% if sub_section %}{% trans "Update sub-section information" %}{% else %}{% trans "Create a new sub-section" %}{% endif %} +

+
+ +
+
+
+ {% csrf_token %} + +
+
+ + {% if sub_section %} + + + {% else %} + + {% endif %} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ {% if sub_section %} + + + {% trans "Cancel" %} + + {% elif preselected_section %} + + + {% trans "Cancel" %} + + {% else %} + + + {% trans "Cancel" %} + + {% endif %} + +
+
+
+
+{% endblock %} diff --git a/templates/organizations/orgsubsection_list.html b/templates/organizations/orgsubsection_list.html new file mode 100644 index 0000000..867e035 --- /dev/null +++ b/templates/organizations/orgsubsection_list.html @@ -0,0 +1,207 @@ +{% extends 'layouts/base.html' %} +{% load i18n %} + +{% block title %}{% trans "Org Sub-Sections" %} - PX360{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} + +
+
+
+

{% trans "Org Sub-Sections" %}

+

{% trans "Manage organization sub-sections" %}

+
+ + {% trans "Add Sub-Section" %} + +
+
+ + +
+
+
+ +
+

{% trans "Filters" %}

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+
+
+
+
+ + +
+
+
+ +
+

{% trans "Sub-Sections List" %}

+
+
+ + + + + + + + + + + + + + {% for sub in sub_sections %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Name (EN)" %}{% trans "Name (AR)" %}{% trans "Code" %}{% trans "Parent Section" %}{% trans "Department" %}{% trans "Status" %}{% trans "Actions" %}
+ {{ sub.name_en }} + + {{ sub.name_ar|default:"-" }} + + {{ sub.code|default:"-" }} + + + {{ sub.section.name_en }} + + + {{ sub.section.department.get_localized_name }} + + {% if sub.status == 'active' %} + {% trans "Active" %} + {% else %} + {% trans "Inactive" %} + {% endif %} + + +
+ +

{% trans "No sub-sections found" %}

+
+
+ + + {% if page_obj.has_other_pages %} +
+
+ {% blocktrans with current=page_obj.number total=page_obj.paginator.num_pages %}{{ current }} of {{ total }}{% endblocktrans %} +
+
+ {% if page_obj.has_previous %} + + + + {% endif %} + + + {{ page_obj.number }} + + + {% if page_obj.has_next %} + + + + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/organizations/patient_visit_journey.html b/templates/organizations/patient_visit_journey.html index f2b37de..027152e 100644 --- a/templates/organizations/patient_visit_journey.html +++ b/templates/organizations/patient_visit_journey.html @@ -203,7 +203,7 @@
- +{{ item.duration_display }} + {{ item.duration_display }}
diff --git a/templates/organizations/staff_detail.html b/templates/organizations/staff_detail.html index 61008ad..3924abf 100644 --- a/templates/organizations/staff_detail.html +++ b/templates/organizations/staff_detail.html @@ -271,10 +271,6 @@ {% trans "Closed" %} {% elif complaint.status == 'cancelled' %} {% trans "Cancelled" %} - {% elif complaint.status == 'contacted' %} - {% trans "Contacted" %} - {% elif complaint.status == 'contacted_no_response' %} - {% trans "No Response" %} {% else %} {{ complaint.get_status_display }} {% endif %} @@ -504,7 +500,15 @@

{% trans "Create a user account for" %} {{ staff.get_full_name }}?

-

{% trans "Credentials will be emailed to" %} {{ staff.email }}.

+

{% trans "Credentials will be emailed to" %} {{ staff.email }}.

+ +
+ + +
@@ -524,7 +528,7 @@

{% trans "Send invitation email to" %} {{ staff.get_full_name }}?

-

{% trans "A new password will be generated and emailed." %}

+

{% trans "A secure password reset link will be emailed." %}

@@ -558,41 +562,27 @@
- - - +
-
-
- - -
- - {{ form.subsection }} - {% if form.subsection.errors %} -
- - {% for error in form.subsection.errors %}{{ error }}{% endfor %} + {% for error in form.section.errors %}{{ error }}{% endfor %}
{% endif %}
@@ -294,83 +279,43 @@ document.addEventListener('DOMContentLoaded', function() { incidentDateInput.setAttribute('max', today); } - // Cascading dropdowns for Location → Section → Subsection - const locationSelect = document.getElementById('{{ form.location.id_for_label }}'); - const mainSectionSelect = document.getElementById('{{ form.main_section.id_for_label }}'); - const subsectionSelect = document.getElementById('{{ form.subsection.id_for_label }}'); + // Cascading dropdowns for Department → Section + const departmentSelect = document.getElementById('{{ form.department.id_for_label }}'); + const sectionSelect = document.getElementById('{{ form.section.id_for_label }}'); - if (locationSelect && mainSectionSelect) { - locationSelect.addEventListener('change', function() { - const locationId = this.value; + if (departmentSelect && sectionSelect) { + departmentSelect.addEventListener('change', function() { + const deptId = this.value; - mainSectionSelect.innerHTML = ''; - subsectionSelect.innerHTML = ''; - mainSectionSelect.disabled = true; - subsectionSelect.disabled = true; + sectionSelect.innerHTML = ''; + sectionSelect.disabled = true; - if (locationId) { - mainSectionSelect.innerHTML = ''; - fetch('{% url "organizations:ajax_main_sections" %}?location_id=' + locationId) + if (deptId) { + sectionSelect.innerHTML = ''; + fetch('/organizations/api/sections/?department=' + deptId) .then(r => r.json()) .then(data => { - const sections = data.sections || []; - mainSectionSelect.innerHTML = ''; - sections.forEach(section => { + const sections = data.results || data; + sectionSelect.innerHTML = ''; + sections.forEach(sec => { const opt = document.createElement('option'); - opt.value = section.id; - opt.textContent = section.name; - mainSectionSelect.appendChild(opt); + opt.value = sec.id; + opt.textContent = sec.name_en || sec.name || sec.display_name; + sectionSelect.appendChild(opt); }); if (sections.length > 0) { - mainSectionSelect.disabled = false; + sectionSelect.disabled = false; } }) .catch(error => { console.error('Error loading sections:', error); - mainSectionSelect.innerHTML = ''; + sectionSelect.innerHTML = ''; }); } }); - if (locationSelect.value) { - locationSelect.dispatchEvent(new Event('change')); - } - } - - if (mainSectionSelect && subsectionSelect) { - mainSectionSelect.addEventListener('change', function() { - const locationId = locationSelect ? locationSelect.value : ''; - const sectionId = this.value; - - subsectionSelect.innerHTML = ''; - subsectionSelect.disabled = true; - - if (locationId && sectionId) { - subsectionSelect.innerHTML = ''; - fetch('{% url "organizations:ajax_subsections" %}?location_id=' + locationId + '&main_section_id=' + sectionId) - .then(r => r.json()) - .then(data => { - const subsections = data.subsections || []; - subsectionSelect.innerHTML = ''; - subsections.forEach(sub => { - const opt = document.createElement('option'); - opt.value = sub.id; - opt.textContent = sub.name; - subsectionSelect.appendChild(opt); - }); - if (subsections.length > 0) { - subsectionSelect.disabled = false; - } - }) - .catch(error => { - console.error('Error loading subsections:', error); - subsectionSelect.innerHTML = ''; - }); - } - }); - - if (mainSectionSelect.value) { - mainSectionSelect.dispatchEvent(new Event('change')); + if (departmentSelect.value) { + departmentSelect.dispatchEvent(new Event('change')); } } }); diff --git a/templates/px_sources/source_user_create_inquiry.html b/templates/px_sources/source_user_create_inquiry.html index 22eb346..7c05753 100644 --- a/templates/px_sources/source_user_create_inquiry.html +++ b/templates/px_sources/source_user_create_inquiry.html @@ -117,27 +117,13 @@
- +
-
- -
- - {{ form.main_section }} -
- -
- - {{ form.subsection }} + {{ form.section }}
@@ -194,27 +180,21 @@ document.addEventListener('DOMContentLoaded', function() { lucide.createIcons(); - const locationSelect = document.getElementById('{{ form.location.id_for_label }}'); - const sectionSelect = document.getElementById('{{ form.main_section.id_for_label }}'); - const subsectionSelect = document.getElementById('{{ form.subsection.id_for_label }}'); + const deptSelect = document.getElementById('id_department'); + const sectionSelect = document.getElementById('id_section'); - if (locationSelect) { - locationSelect.addEventListener('change', function() { - const locationId = this.value; + if (deptSelect && sectionSelect) { + deptSelect.addEventListener('change', function() { + const deptId = this.value; sectionSelect.innerHTML = ''; - subsectionSelect.innerHTML = ''; - sectionSelect.disabled = true; - subsectionSelect.disabled = true; - - if (!locationId) { + if (!deptId) { sectionSelect.innerHTML = ''; return; } - - fetch('{% url "organizations:ajax_main_sections" %}?location_id=' + locationId) + fetch('/organizations/api/sections/?department=' + deptId) .then(r => r.json()) .then(data => { - const sections = data.sections || []; + const sections = data.results || data; sectionSelect.innerHTML = ''; sections.forEach(s => { const opt = document.createElement('option'); @@ -222,7 +202,6 @@ document.addEventListener('DOMContentLoaded', function() { opt.textContent = s.name; sectionSelect.appendChild(opt); }); - if (sections.length > 0) sectionSelect.disabled = false; }) .catch(err => { console.error('Error loading sections:', err); @@ -230,44 +209,8 @@ document.addEventListener('DOMContentLoaded', function() { }); }); - if (locationSelect.value) { - locationSelect.dispatchEvent(new Event('change')); - } - } - - if (sectionSelect) { - sectionSelect.addEventListener('change', function() { - const locationId = locationSelect ? locationSelect.value : ''; - const sectionId = this.value; - subsectionSelect.innerHTML = ''; - subsectionSelect.disabled = true; - - if (!sectionId) { - subsectionSelect.innerHTML = ''; - return; - } - - fetch('{% url "organizations:ajax_subsections" %}?location_id=' + locationId + '&main_section_id=' + sectionId) - .then(r => r.json()) - .then(data => { - const subsections = data.subsections || []; - subsectionSelect.innerHTML = ''; - subsections.forEach(s => { - const opt = document.createElement('option'); - opt.value = s.id; - opt.textContent = s.name; - subsectionSelect.appendChild(opt); - }); - if (subsections.length > 0) subsectionSelect.disabled = false; - }) - .catch(err => { - console.error('Error loading subsections:', err); - subsectionSelect.innerHTML = ''; - }); - }); - - if (sectionSelect.value) { - sectionSelect.dispatchEvent(new Event('change')); + if (deptSelect.value) { + deptSelect.dispatchEvent(new Event('change')); } } }); diff --git a/templates/px_sources/source_user_create_observation.html b/templates/px_sources/source_user_create_observation.html index bae35b6..ffc69a5 100644 --- a/templates/px_sources/source_user_create_observation.html +++ b/templates/px_sources/source_user_create_observation.html @@ -125,31 +125,17 @@
-
- -
- - {{ form.main_section }} -
- -
- - {{ form.subsection }} -
@@ -204,27 +190,18 @@ diff --git a/templates/rca/rca_detail.html b/templates/rca/rca_detail.html index ca94ea4..7f7c957 100644 --- a/templates/rca/rca_detail.html +++ b/templates/rca/rca_detail.html @@ -60,12 +60,12 @@ {% endif %}
- {% if rca.status != 'closed' %} + {% if can_edit and rca.status != 'closed' %} {% trans "Edit" %} {% endif %} - {% if rca.status == 'review' %} + {% if can_approve and rca.status == 'review' %}
{% csrf_token %} @@ -74,7 +74,7 @@
{% endif %} - {% if rca.status == 'approved' or rca.status == 'in_progress' %} + {% if can_approve and rca.status == 'approved' or rca.status == 'in_progress' %}
{% csrf_token %} @@ -84,7 +84,7 @@
{% endif %} - {% if rca.status != 'closed' %} + {% if can_delete and rca.status != 'closed' %}
{% csrf_token %}