diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..a853208
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,41 @@
+# PX360 deployment helpers for the `target` tool.
+#
+# First time (per environment):
+# make init-dev # creates deploy/env.dev from the template
+# $EDITOR deploy/env.dev # fill in SECRET_KEY, API keys, DB password, etc.
+#
+# Deploy (builds image, ships the whole .env, runs compose up):
+# make deploy-dev
+# make deploy-prod
+#
+# Each environment is a single gitignored file (deploy/env.dev / deploy/env.prod)
+# holding ALL config + secrets. `target deploy` ships it to the server as .env.
+
+.PHONY: deploy-dev deploy-prod init-dev init-prod check-dev check-prod
+
+# --- One-time: create the gitignored env file from the committed template ---
+init-dev:
+ cp -n deploy/env.dev.example deploy/env.dev 2>/dev/null || true
+ @echo "Created deploy/env.dev — edit it and fill in the secrets, then: make deploy-dev"
+
+init-prod:
+ cp -n deploy/env.prod.example deploy/env.prod 2>/dev/null || true
+ @echo "Created deploy/env.prod — edit it and fill in the secrets, then: make deploy-prod"
+
+# --- Guards ---
+check-dev:
+ @if [ ! -f deploy/env.dev ]; then \
+ echo "ERROR: deploy/env.dev not found. Run: make init-dev"; exit 1; \
+ fi
+
+check-prod:
+ @if [ ! -f deploy/env.prod ]; then \
+ echo "ERROR: deploy/env.prod not found. Run: make init-prod"; exit 1; \
+ fi
+
+# --- Deploy (builds image, ships .env, runs compose up) ---
+deploy-dev: check-dev
+ target deploy --config deploy/target.dev.yaml
+
+deploy-prod: check-prod
+ target deploy --config deploy/target.prod.yaml
diff --git a/apps/complaints/management/commands/load_shct_taxonomy.py b/apps/complaints/management/commands/load_shct_taxonomy.py
index 5333f0f..3c7287a 100644
--- a/apps/complaints/management/commands/load_shct_taxonomy.py
+++ b/apps/complaints/management/commands/load_shct_taxonomy.py
@@ -22,7 +22,7 @@ class Command(BaseCommand):
def handle(self, *args, **kwargs):
# Full SHCT Data Structure: (EN, AR)
shct_data = {
- ('CLINICAL', 'سريري'): {
+ ('Clinical Care', 'سريري'): {
('Quality', 'الجودة'): {
('Examination', 'الفحص'): [
('Examination not performed', 'لم يتم إجراء الفحص'),
@@ -87,7 +87,7 @@ class Command(BaseCommand):
]
}
},
- ('MANAGEMENT', 'إداري'): {
+ ('Management', 'إداري'): {
('Institutional Issues', 'القضايا المؤسسية'): {
('Administrative Policies', 'السياسات الإدارية'): [
('Paperwork delays', 'تأخير في المعاملات الورقية'),
@@ -129,7 +129,7 @@ class Command(BaseCommand):
],
}
},
- ('RELATIONSHIPS', 'علاقات'): {
+ ('Relationships', 'علاقات'): {
('Communication', 'التواصل'): {
('Patient-staff communication', 'التواصل بين المريض والموظفين'): [
('Miscommunication with Patient', 'سوء فهم مع المريض'),
@@ -171,12 +171,16 @@ class Command(BaseCommand):
total_created = 0
for (dom_en, dom_ar), categories in shct_data.items():
- # Level 1: Domain
+ # Level 1: Domain — look up by name_en + level only so existing
+ # records (which may have different Arabic name/domain_type) are
+ # found instead of being duplicated.
domain, created = ComplaintCategory.objects.get_or_create(
name_en=dom_en,
- name_ar=dom_ar,
level=ComplaintCategory.LevelChoices.DOMAIN,
- domain_type=dom_en.upper()
+ defaults={
+ "name_ar": dom_ar,
+ "domain_type": dom_en.upper(),
+ },
)
if created:
domains_created += 1
diff --git a/apps/complaints/migrations/0030_complaint_taxonomy_review.py b/apps/complaints/migrations/0030_complaint_taxonomy_review.py
new file mode 100644
index 0000000..e006c57
--- /dev/null
+++ b/apps/complaints/migrations/0030_complaint_taxonomy_review.py
@@ -0,0 +1,26 @@
+# Generated by Django 6.0.1 on 2026-07-01 13:14
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('complaints', '0029_investigationstatus_direct_reply'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='complaint',
+ name='taxonomy_reviewed_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='complaint',
+ name='taxonomy_reviewed_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_taxonomy_complaints', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/apps/complaints/migrations/0031_complaint_satisfaction_set_by.py b/apps/complaints/migrations/0031_complaint_satisfaction_set_by.py
new file mode 100644
index 0000000..d9e6627
--- /dev/null
+++ b/apps/complaints/migrations/0031_complaint_satisfaction_set_by.py
@@ -0,0 +1,21 @@
+# Generated by Django 6.0.1 on 2026-07-01 20:45
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('complaints', '0030_complaint_taxonomy_review'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='complaint',
+ name='satisfaction_set_by',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='set_satisfaction_complaints', to=settings.AUTH_USER_MODEL),
+ ),
+ ]
diff --git a/apps/complaints/migrations/0032_complaint_ai_response_suggestions.py b/apps/complaints/migrations/0032_complaint_ai_response_suggestions.py
new file mode 100644
index 0000000..ac8f051
--- /dev/null
+++ b/apps/complaints/migrations/0032_complaint_ai_response_suggestions.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-07-01 22:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('complaints', '0031_complaint_satisfaction_set_by'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='complaint',
+ name='ai_response_suggestions',
+ field=models.JSONField(blank=True, default=dict),
+ ),
+ migrations.AddField(
+ model_name='complaint',
+ name='ai_response_suggestions_at',
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/apps/complaints/migrations/0033_complaint_satisfaction_change_count.py b/apps/complaints/migrations/0033_complaint_satisfaction_change_count.py
new file mode 100644
index 0000000..f3d8874
--- /dev/null
+++ b/apps/complaints/migrations/0033_complaint_satisfaction_change_count.py
@@ -0,0 +1,18 @@
+# Generated by Django 6.0.1 on 2026-07-01 23:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('complaints', '0032_complaint_ai_response_suggestions'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='complaint',
+ name='satisfaction_change_count',
+ field=models.IntegerField(default=0),
+ ),
+ ]
diff --git a/apps/complaints/migrations/0034_remove_inquiry_observation_satisfaction.py b/apps/complaints/migrations/0034_remove_inquiry_observation_satisfaction.py
new file mode 100644
index 0000000..1ab2234
--- /dev/null
+++ b/apps/complaints/migrations/0034_remove_inquiry_observation_satisfaction.py
@@ -0,0 +1,21 @@
+# Generated by Django 6.0.1 on 2026-07-04 19:55
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('complaints', '0033_complaint_satisfaction_change_count'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='inquiry',
+ name='satisfaction',
+ ),
+ migrations.RemoveField(
+ model_name='inquiry',
+ name='satisfaction_set_at',
+ ),
+ ]
diff --git a/apps/complaints/migrations/0035_involved_department_routing_rejection.py b/apps/complaints/migrations/0035_involved_department_routing_rejection.py
new file mode 100644
index 0000000..0770ce8
--- /dev/null
+++ b/apps/complaints/migrations/0035_involved_department_routing_rejection.py
@@ -0,0 +1,57 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("complaints", "0034_remove_inquiry_observation_satisfaction"),
+ ("organizations", "0014_remove_department_manager_1st"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="complaintinvolveddepartment",
+ name="routing_status",
+ field=models.CharField(
+ choices=[("sent", "Sent"), ("accepted", "Accepted"), ("rejected", "Rejected")],
+ default="sent",
+ help_text="Whether the department has accepted or rejected the routing of this complaint",
+ max_length=20,
+ ),
+ ),
+ migrations.AddField(
+ model_name="complaintinvolveddepartment",
+ name="rejected_at",
+ field=models.DateTimeField(blank=True, help_text="When the champion rejected the routing", null=True),
+ ),
+ migrations.AddField(
+ model_name="complaintinvolveddepartment",
+ name="rejected_by_staff",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Staff member (champion/manager) who rejected the routing",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="rejected_dept_involvements",
+ to="organizations.staff",
+ ),
+ ),
+ migrations.AddField(
+ model_name="complaintinvolveddepartment",
+ name="rejection_reason",
+ field=models.TextField(blank=True, help_text="Reason the department rejected the routing"),
+ ),
+ migrations.AddField(
+ model_name="complaintinvolveddepartment",
+ name="suggested_department",
+ field=models.ForeignKey(
+ blank=True,
+ help_text="Department the champion suggested as the correct one",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="suggested_for_involvements",
+ to="organizations.department",
+ ),
+ ),
+ ]
diff --git a/apps/complaints/models.py b/apps/complaints/models.py
index a825b95..52696b2 100644
--- a/apps/complaints/models.py
+++ b/apps/complaints/models.py
@@ -589,12 +589,45 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
)
satisfaction_set_at = models.DateTimeField(null=True, blank=True, help_text="When satisfaction was last set")
+ # Who set the satisfaction (PX-team only; patient locks via satisfaction_locked_by_patient)
+ satisfaction_set_by = models.ForeignKey(
+ "accounts.User",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="set_satisfaction_complaints",
+ )
+
# Patient lock — once the patient submits satisfaction via the public tracking
# page, the value becomes authoritative and can no longer be edited by anyone
# (neither patient nor PX-team).
satisfaction_locked_by_patient = models.BooleanField(default=False, db_index=True)
satisfaction_locked_at = models.DateTimeField(null=True, blank=True)
+ # Taxonomy review gate — PX-team must review/confirm the AI-generated
+ # classification before the complaint can be sent to the department.
+ # Once set, stays set permanently (taxonomy remains editable afterwards,
+ # but the gate is passed once).
+ taxonomy_reviewed_at = models.DateTimeField(null=True, blank=True)
+ taxonomy_reviewed_by = models.ForeignKey(
+ "accounts.User",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="reviewed_taxonomy_complaints",
+ )
+
+ # AI response suggestions — persisted bilingual output from the
+ # "Response Suggestions" button in the Resolution tab.
+ ai_response_suggestions = models.JSONField(default=dict, blank=True)
+ ai_response_suggestions_at = models.DateTimeField(null=True, blank=True)
+
+ # PX-team satisfaction change budget — only "satisfied", "neutral", and
+ # "dissatisfied" consume this counter. "no_response" does NOT count.
+ # Patient override is separate (satisfaction_locked_by_patient).
+ satisfaction_change_count = models.IntegerField(default=0)
+ MAX_SATISFACTION_CHANGES = 3
+
# External references
moh_reference = models.CharField(max_length=100, blank=True, help_text="Ministry of Health reference number")
moh_reference_date = models.DateField(null=True, blank=True, help_text="MOH reference date")
@@ -862,6 +895,22 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel):
ComplaintStatus.PENDING_EXTERNAL,
]
+ @property
+ def satisfaction_window_expired(self):
+ """True if 5+ days have passed since resolution/closure.
+
+ Mirrors the tracking-page expiry in core/views.py.
+ When expired, satisfaction can no longer be set or changed by anyone.
+ """
+ if not self.resolved_at and not self.closed_at:
+ return False
+ from datetime import timedelta
+
+ from django.utils import timezone
+
+ expiry_base = self.resolved_at or self.closed_at
+ return timezone.now() > expiry_base + timedelta(days=5)
+
@property
def sla_time_remaining(self):
if not self.due_at:
@@ -1834,13 +1883,6 @@ class Inquiry(UUIDModel, TimeStampedModel, SoftDeleteModel):
"accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_inquiries"
)
- # Satisfaction
- satisfaction = models.CharField(
- max_length=20, blank=True, default="",
- choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
- )
- satisfaction_set_at = models.DateTimeField(null=True, blank=True)
-
# Metadata (stores AI analysis, form data, etc.)
metadata = models.JSONField(default=dict, blank=True)
@@ -2799,6 +2841,37 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
)
manager_reviewed_at = models.DateTimeField(null=True, blank=True)
+ # Routing acceptance / rejection (champion can reject a wrongly-routed complaint)
+ class RoutingStatus(models.TextChoices):
+ SENT = "sent", _("Sent")
+ ACCEPTED = "accepted", _("Accepted")
+ REJECTED = "rejected", _("Rejected")
+
+ routing_status = models.CharField(
+ max_length=20,
+ choices=RoutingStatus.choices,
+ default=RoutingStatus.SENT,
+ help_text="Whether the department has accepted or rejected the routing of this complaint",
+ )
+ rejected_at = models.DateTimeField(null=True, blank=True, help_text="When the champion rejected the routing")
+ rejected_by_staff = models.ForeignKey(
+ "organizations.Staff",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="rejected_dept_involvements",
+ help_text="Staff member (champion/manager) who rejected the routing",
+ )
+ rejection_reason = models.TextField(blank=True, help_text="Reason the department rejected the routing")
+ suggested_department = models.ForeignKey(
+ "organizations.Department",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="suggested_for_involvements",
+ help_text="Department the champion suggested as the correct one",
+ )
+
class Meta:
ordering = ["-is_primary", "-created_at"]
verbose_name = "Complaint Involved Department"
@@ -2839,6 +2912,17 @@ class ComplaintInvolvedDepartment(UUIDModel, TimeStampedModel):
return timedelta(0)
return remaining
+ @property
+ def can_reject_routing(self):
+ """Whether the champion can still reject this routing as 'wrong department'."""
+ if self.response_submitted:
+ return False
+ if self.routing_status != self.RoutingStatus.SENT:
+ return False
+ if self.complaint_id and self.complaint.status in ("closed", "cancelled"):
+ return False
+ return True
+
class ComplaintInvolvedStaff(UUIDModel, TimeStampedModel):
"""
diff --git a/apps/complaints/services/complaint_service.py b/apps/complaints/services/complaint_service.py
index 30dc049..0643af6 100644
--- a/apps/complaints/services/complaint_service.py
+++ b/apps/complaints/services/complaint_service.py
@@ -7,7 +7,13 @@ from django.utils import timezone
from apps.core.services import AuditService
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
+from apps.complaints.models import (
+ Complaint,
+ ComplaintExplanation,
+ ComplaintInvolvedDepartment,
+ ComplaintStatus,
+ ComplaintUpdate,
+)
logger = logging.getLogger(__name__)
@@ -927,7 +933,19 @@ This is an automated message from PX360 Complaint Management System."""
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()
+ inv_dept.save(update_fields=["sent", "sent_at", "forwarded_at"])
+ # If re-sending to a department that previously rejected the routing,
+ # reset the rejection so the champion can act again.
+ if not created and inv_dept.routing_status == ComplaintInvolvedDepartment.RoutingStatus.REJECTED:
+ inv_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
+ inv_dept.rejected_at = None
+ inv_dept.rejected_by_staff = None
+ inv_dept.rejection_reason = ""
+ inv_dept.suggested_department = None
+ inv_dept.save(update_fields=[
+ "routing_status", "rejected_at", "rejected_by_staff",
+ "rejection_reason", "suggested_department",
+ ])
metadata = {
"champion_count": champion_count,
@@ -1045,3 +1063,139 @@ This is an automated message from PX360 Complaint Management System."""
content_object=complaint,
metadata=metadata,
)
+
+
+class RoutingRejectionError(Exception):
+ """Raised when a routing rejection is not allowed."""
+
+
+def reject_department_routing(involved_dept, *, staff=None, user=None, reason="", suggested_department=None):
+ """
+ Mark a ComplaintInvolvedDepartment as rejected (wrong department).
+
+ Shared between the no-login token view and the logged-in champion view.
+ - Marks routing_status=REJECTED, sets rejected_at / rejected_by_staff /
+ rejection_reason / suggested_department.
+ - If the rejected department is the complaint's primary, clears
+ complaint.department / complaint.section so PX can reassign.
+ - Creates a ComplaintUpdate timeline entry + an AuditEvent.
+ - Notifies the complaint handler (complaint.assigned_to) and PX admins by email.
+
+ Returns the updated involved_dept.
+ Raises RoutingRejectionError if the routing cannot be rejected.
+ """
+ if not involved_dept.can_reject_routing:
+ raise RoutingRejectionError("This routing can no longer be rejected.")
+
+ complaint = involved_dept.complaint
+ department = involved_dept.department
+ now = timezone.now()
+
+ involved_dept.routing_status = ComplaintInvolvedDepartment.RoutingStatus.REJECTED
+ involved_dept.rejected_at = now
+ involved_dept.rejected_by_staff = staff
+ involved_dept.rejection_reason = reason
+ involved_dept.suggested_department = suggested_department
+ involved_dept.save(update_fields=[
+ "routing_status", "rejected_at", "rejected_by_staff",
+ "rejection_reason", "suggested_department",
+ ])
+
+ was_primary = involved_dept.is_primary or complaint.department_id == department.pk
+ if was_primary and complaint.department_id == department.pk:
+ complaint.department = None
+ complaint.section = None
+ complaint.save(update_fields=["department", "section"])
+
+ actor_label = staff.get_full_name() if staff else (user.get_full_name() if user else "Champion")
+
+ suggested_label = ""
+ if suggested_department is not None:
+ suggested_label = f" Suggested department: {suggested_department.get_localized_name()}."
+
+ ComplaintUpdate.objects.create(
+ complaint=complaint,
+ update_type="note",
+ message=(
+ f"{department.get_localized_name()} rejected the routing "
+ f"(wrong department). Reason: {reason or 'No reason provided.'}.{suggested_label}"
+ ),
+ created_by=user,
+ metadata={
+ "department_id": str(department.id),
+ "staff_id": str(staff.id) if staff else None,
+ "reason": reason,
+ "suggested_department_id": str(suggested_department.id) if suggested_department else None,
+ "was_primary": was_primary,
+ },
+ )
+
+ AuditService.log_event(
+ event_type="dept_routing_rejected",
+ description=(
+ f"{actor_label} rejected routing of complaint {complaint.reference_number} "
+ f"to {department.get_localized_name()} (wrong department)."
+ ),
+ user=user,
+ content_object=complaint,
+ metadata={
+ "department_id": str(department.id),
+ "reason": reason,
+ "suggested_department_id": str(suggested_department.id) if suggested_department else None,
+ "was_primary": was_primary,
+ },
+ )
+
+ _notify_routing_rejected(
+ complaint=complaint,
+ department=department,
+ reason=reason,
+ suggested_label=suggested_label,
+ actor_label=actor_label,
+ )
+
+ return involved_dept
+
+
+def _notify_routing_rejected(*, complaint, department, reason, suggested_label, actor_label):
+ """Email + in-app notification to the complaint handler and PX admins."""
+ subject = f"Department Rejected Routing - {complaint.reference_number}"
+ plain = (
+ f"{department.get_localized_name()} has rejected the routing of complaint "
+ f"{complaint.reference_number} as it was sent to the wrong department.\n\n"
+ f"Rejected by: {actor_label}\n"
+ f"Reason: {reason or 'No reason provided.'}{suggested_label}\n\n"
+ f"Please review and re-route to the correct department.\n"
+ )
+ html = f"""
+
+ {get_email_header_html()}
+
+
Department Rejected Routing
+
{department.get_localized_name()} has rejected the routing of
+ complaint #{complaint.reference_number} as it was sent to the wrong department.
+
Rejected by: {actor_label}
+
Reason: {reason or 'No reason provided.'}{suggested_label}
+
Please review and re-route to the correct department.
+
+
+ """
+
+ recipients = []
+ if complaint.assigned_to and complaint.assigned_to.email:
+ recipients.append(complaint.assigned_to)
+
+ for recipient in recipients:
+ try:
+ NotificationService.send_email(
+ email=recipient.email,
+ subject=subject,
+ message=plain,
+ html_message=html,
+ related_object=complaint,
+ user=recipient,
+ notification_type="dept_routing_rejected",
+ )
+ except Exception:
+ logger.exception("Failed to send routing-rejected notification for complaint %s", complaint.id)
+
diff --git a/apps/complaints/tests_routing_rejection.py b/apps/complaints/tests_routing_rejection.py
new file mode 100644
index 0000000..b2c063b
--- /dev/null
+++ b/apps/complaints/tests_routing_rejection.py
@@ -0,0 +1,260 @@
+"""
+Tests for the champion "reject wrong-department routing" feature.
+
+Covers:
+- Token-based (no-login) rejection via complaint_explanation_form
+- Login-based rejection via involved_department_reject_routing
+- Primary department rejection clears complaint.department
+- Guards: can't reject twice, can't reject after response submitted
+- Re-send to a rejected department resets the rejection
+"""
+import secrets
+
+from django.contrib.auth.models import Group
+from django.test import TestCase
+from django.urls import reverse
+
+from apps.accounts.models import User
+from apps.complaints.models import (
+ Complaint,
+ ComplaintExplanation,
+ ComplaintInvolvedDepartment,
+ ComplaintUpdate,
+)
+from apps.complaints.services.complaint_service import (
+ RoutingRejectionError,
+ reject_department_routing,
+)
+from apps.organizations.models import Department, Hospital, Staff
+
+
+class RoutingRejectionBase(TestCase):
+ """Shared fixtures: hospital, two departments, PX admin, champion, complaint."""
+
+ @classmethod
+ def _make_staff(cls, first_name, last_name, emp_id, hospital, department):
+ return Staff.objects.create(
+ first_name=first_name,
+ last_name=last_name,
+ staff_type="admin",
+ job_title="Champion",
+ employee_id=emp_id,
+ hospital=hospital,
+ department=department,
+ status="active",
+ )
+
+ def setUp(self):
+ self.hospital = Hospital.objects.create(name="Test Hosp", code="TH001", status="active")
+ self.dept_a = Department.objects.create(
+ hospital=self.hospital, name="Dept A", name_en="Dept A", code="DA", status="active",
+ )
+ self.dept_b = Department.objects.create(
+ hospital=self.hospital, name="Dept B", name_en="Dept B", code="DB", status="active",
+ )
+
+ # PX admin
+ self.px_admin = User.objects.create_user(
+ email="px@test.com", password="pass123", hospital=self.hospital,
+ )
+ px_group, _ = Group.objects.get_or_create(name="PX Admin")
+ self.px_admin.groups.add(px_group)
+
+ # Champion of dept_a
+ self.champ_user = User.objects.create_user(
+ email="champ@test.com", password="pass123",
+ hospital=self.hospital, department=self.dept_a,
+ )
+ self.champ_staff = self._make_staff(
+ "Champ", "One", "EMP-CHAMP-1", self.hospital, self.dept_a,
+ )
+ self.champ_staff.user = self.champ_user
+ self.champ_staff.save()
+ self.dept_a.champion = self.champ_staff
+ self.dept_a.save(update_fields=["champion"])
+
+ # Complaint routed to dept_a (primary, sent)
+ self.complaint = Complaint.objects.create(
+ hospital=self.hospital,
+ department=self.dept_a,
+ title="Wrong routing test",
+ description="Sent to the wrong department.",
+ created_by=self.px_admin,
+ status="in_progress",
+ )
+ self.involved = ComplaintInvolvedDepartment.objects.create(
+ complaint=self.complaint,
+ department=self.dept_a,
+ role="primary",
+ is_primary=True,
+ sent=True,
+ )
+
+
+class ModelHelperTests(RoutingRejectionBase):
+ def test_can_reject_routing_initially_true(self):
+ self.assertTrue(self.involved.can_reject_routing)
+
+ def test_cannot_reject_after_response_submitted(self):
+ self.involved.response_submitted = True
+ self.involved.save()
+ self.assertFalse(self.involved.can_reject_routing)
+
+ def test_cannot_reject_twice(self):
+ reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
+ self.involved.refresh_from_db()
+ self.assertFalse(self.involved.can_reject_routing)
+ with self.assertRaises(RoutingRejectionError):
+ reject_department_routing(self.involved, staff=self.champ_staff, reason="again")
+
+
+class ServiceRejectTests(RoutingRejectionBase):
+ def test_service_reject_marks_fields(self):
+ reject_department_routing(
+ self.involved,
+ staff=self.champ_staff,
+ reason="not ours",
+ suggested_department=self.dept_b,
+ )
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "rejected")
+ self.assertIsNotNone(self.involved.rejected_at)
+ self.assertEqual(self.involved.rejected_by_staff_id, self.champ_staff.id)
+ self.assertEqual(self.involved.rejection_reason, "not ours")
+ self.assertEqual(self.involved.suggested_department_id, self.dept_b.id)
+
+ def test_service_reject_primary_clears_complaint_department(self):
+ reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
+ self.complaint.refresh_from_db()
+ self.assertIsNone(self.complaint.department_id)
+ self.assertIsNone(self.complaint.section_id)
+
+ def test_service_reject_secondary_keeps_other_department(self):
+ # Make dept_b a secondary involved department
+ inv_b = ComplaintInvolvedDepartment.objects.create(
+ complaint=self.complaint, department=self.dept_b, role="secondary", sent=True,
+ )
+ reject_department_routing(inv_b, staff=self.champ_staff, reason="wrong")
+ # Primary dept_a must remain untouched
+ self.complaint.refresh_from_db()
+ self.assertEqual(self.complaint.department_id, self.dept_a.id)
+
+ def test_service_reject_creates_update_and_audit(self):
+ reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong dept")
+ self.assertTrue(
+ ComplaintUpdate.objects.filter(
+ complaint=self.complaint, update_type="note",
+ ).exists()
+ )
+
+
+class TokenViewRejectTests(RoutingRejectionBase):
+ def setUp(self):
+ super().setUp()
+ self.explanation = ComplaintExplanation.objects.create(
+ complaint=self.complaint,
+ staff=self.champ_staff,
+ token=secrets.token_urlsafe(32),
+ explanation="",
+ is_used=False,
+ )
+ self.url = reverse(
+ "complaints:complaint_explanation_form",
+ kwargs={"complaint_id": self.complaint.id, "token": self.explanation.token},
+ )
+
+ def test_token_reject_success(self):
+ resp = self.client.post(self.url, {
+ "action": "reject_routing",
+ "rejection_reason": "Belongs to another dept",
+ })
+ self.assertEqual(resp.status_code, 200)
+ self.assertTemplateUsed(resp, "complaints/explanation_routing_rejected.html")
+
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "rejected")
+ self.assertEqual(self.involved.rejection_reason, "Belongs to another dept")
+
+ # Token consumed
+ self.explanation.refresh_from_db()
+ self.assertTrue(self.explanation.is_used)
+
+ def test_token_reject_requires_reason(self):
+ resp = self.client.post(self.url, {"action": "reject_routing", "rejection_reason": ""})
+ self.assertEqual(resp.status_code, 200)
+ self.assertTemplateUsed(resp, "complaints/explanation_form.html")
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "sent")
+
+ def test_token_reject_already_used_token_blocked(self):
+ self.explanation.is_used = True
+ self.explanation.save()
+ resp = self.client.post(self.url, {
+ "action": "reject_routing", "rejection_reason": "x",
+ })
+ # Already-submitted template is rendered when token is used
+ self.assertTemplateUsed(resp, "complaints/explanation_already_submitted.html")
+
+
+class LoginViewRejectTests(RoutingRejectionBase):
+ def test_login_reject_by_champion(self):
+ self.client.login(username="champ@test.com", password="pass123")
+ url = reverse(
+ "complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
+ )
+ resp = self.client.post(url, {"rejection_reason": "Wrong department"})
+ self.assertEqual(resp.status_code, 302)
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "rejected")
+
+ def test_login_reject_requires_permission(self):
+ # A random user with no relation to the department
+ User.objects.create_user(
+ email="other@test.com", password="pass123", hospital=self.hospital,
+ )
+ self.client.login(username="other@test.com", password="pass123")
+ url = reverse(
+ "complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
+ )
+ resp = self.client.post(url, {"rejection_reason": "Wrong department"})
+ self.assertEqual(resp.status_code, 302) # redirected, but not rejected
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "sent")
+
+ def test_login_reject_requires_reason(self):
+ self.client.login(username="champ@test.com", password="pass123")
+ url = reverse(
+ "complaints:involved_department_reject_routing", kwargs={"pk": self.involved.pk},
+ )
+ resp = self.client.post(url, {"rejection_reason": ""})
+ self.assertEqual(resp.status_code, 302)
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "sent")
+
+
+class ResendResetTests(RoutingRejectionBase):
+ def test_resend_to_rejected_department_resets_routing(self):
+ reject_department_routing(self.involved, staff=self.champ_staff, reason="wrong")
+ self.involved.refresh_from_db()
+ self.assertEqual(self.involved.routing_status, "rejected")
+
+ # Simulate re-send via the service path
+ from django.utils import timezone
+
+ inv, created = ComplaintInvolvedDepartment.objects.get_or_create(
+ complaint=self.complaint, department=self.dept_a,
+ defaults={"role": "primary", "sent": True, "sent_at": timezone.now()},
+ )
+ self.assertFalse(created)
+ inv.routing_status = ComplaintInvolvedDepartment.RoutingStatus.SENT
+ inv.rejected_at = None
+ inv.rejected_by_staff = None
+ inv.rejection_reason = ""
+ inv.suggested_department = None
+ inv.sent = True
+ inv.sent_at = timezone.now()
+ inv.save()
+
+ inv.refresh_from_db()
+ self.assertEqual(inv.routing_status, "sent")
+ self.assertEqual(inv.rejection_reason, "")
diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py
index 321b64c..f2b7c5c 100644
--- a/apps/complaints/ui_views.py
+++ b/apps/complaints/ui_views.py
@@ -639,7 +639,13 @@ def complaint_detail(request, pk):
hospital_departments = []
if complaint.hospital:
- hospital_departments = Department.objects.filter(hospital=complaint.hospital, status="active").order_by("name")
+ from django.db.models import Q
+
+ hospital_departments = Department.objects.filter(
+ hospital=complaint.hospital, status="active",
+ ).filter(
+ Q(champion__isnull=False) | Q(manager__isnull=False)
+ ).order_by("name")
if complaint.is_active_status and not complaint.is_overdue:
complaint.check_overdue()
@@ -651,25 +657,53 @@ def complaint_detail(request, pk):
escalation_targets = []
default_escalation_target = None
+ # Build escalation list from Manager + Deputy Manager of ALL involved departments
+ involved_depts = list(
+ complaint.involved_departments.select_related("department", "department__manager")
+ .exclude(department__isnull=True)
+ )
+ dept_label_map = {}
+ departments_to_check = []
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)
+ departments_to_check.append(complaint.department)
+ dept_label_map[complaint.department.id] = complaint.department.get_localized_name()
+ for idept in involved_depts:
+ if idept.department and idept.department.id not in dept_label_map:
+ departments_to_check.append(idept.department)
+ dept_label_map[idept.department.id] = idept.department.get_localized_name()
+
+ seen_staff_ids = set()
+ for dept in departments_to_check:
+ dept_label = dept_label_map.get(dept.id, dept.name)
+
+ # Deputy Manager (Staff FK)
+ if dept.deputy_manager and dept.deputy_manager.id not in seen_staff_ids:
+ has_user = bool(dept.deputy_manager.user and dept.deputy_manager.user.is_active)
+ if has_user or dept.deputy_manager.email:
+ seen_staff_ids.add(dept.deputy_manager.id)
+ escalation_targets.append({
+ "staff": dept.deputy_manager,
+ "has_user": has_user,
+ "user_id": str(dept.deputy_manager.user.id) if has_user else "",
+ "role_label": _("Deputy Manager"),
+ "group": dept_label,
+ })
+
+ # Primary Manager (User FK — bridge via staff_profile)
+ if dept.manager:
+ manager_staff = getattr(dept.manager, "staff_profile", None)
+ if manager_staff and manager_staff.id not in seen_staff_ids:
+ seen_staff_ids.add(manager_staff.id)
+ escalation_targets.append({
+ "staff": manager_staff,
+ "has_user": True,
+ "user_id": str(dept.manager.id),
+ "role_label": _("Manager"),
+ "group": dept_label,
+ })
+
+ if escalation_targets:
+ default_escalation_target = str(escalation_targets[0]["staff"].id)
adverse_actions = complaint.adverse_actions.all()
@@ -711,6 +745,7 @@ def complaint_detail(request, pk):
"is_active_status": complaint.is_active_status,
"workflow_steps": {
"activated": complaint.activated_at is not None,
+ "taxonomy_reviewed": complaint.taxonomy_reviewed_at is not None,
"sent_to_department": complaint.sent_to_department or complaint.forwarded_to_dept_at is not None,
"department_responded": (
complaint.explanations.filter(is_used=True).exists()
@@ -719,6 +754,30 @@ def complaint_detail(request, pk):
"resolved": complaint.status in ("resolved", "closed"),
"cancelled": complaint.status == "cancelled",
},
+ "dept_response_progress": {
+ "responded": complaint.involved_departments.filter(sent=True, response_submitted=True).count(),
+ "total": complaint.involved_departments.filter(sent=True).count(),
+ "pending": list(
+ complaint.involved_departments.filter(
+ sent=True, response_submitted=False
+ ).select_related("department").values_list("department__name", flat=True)
+ ),
+ "responded_list": [
+ {
+ "name": d.department.get_localized_name() if d.department else "—",
+ "at": d.response_submitted_at,
+ }
+ for d in complaint.involved_departments.filter(
+ sent=True, response_submitted=True
+ ).select_related("department")
+ ],
+ },
+ "satisfaction_history": ComplaintUpdate.objects.filter(
+ complaint=complaint, message__icontains="satisfaction"
+ ).order_by("-created_at")[:5],
+ "satisfaction_changes_remaining": max(
+ 0, Complaint.MAX_SATISFACTION_CHANGES - complaint.satisfaction_change_count
+ ),
"can_collect_feedback": bool(
complaint.department
and complaint.is_active_status
@@ -1145,7 +1204,17 @@ def complaint_send_to(request, pk):
involved_dept.forwarded_at = now
involved_dept.sent = True
involved_dept.sent_at = now
- involved_dept.save()
+ # Reset any prior rejection so the champion can act again
+ involved_dept.routing_status = "sent"
+ involved_dept.rejected_at = None
+ involved_dept.rejected_by_staff = None
+ involved_dept.rejection_reason = ""
+ involved_dept.suggested_department = None
+ involved_dept.save(update_fields=[
+ "forwarded_at", "sent", "sent_at",
+ "routing_status", "rejected_at", "rejected_by_staff",
+ "rejection_reason", "suggested_department",
+ ])
complaint.sent_to_department = True
complaint.sent_to_department_at = complaint.sent_to_department_at or now
@@ -1355,20 +1424,78 @@ def update_satisfaction(request, pk):
)
return redirect("complaints:complaint_detail", pk=pk)
+ if complaint.satisfaction_window_expired:
+ messages.error(
+ request,
+ _("Satisfaction can no longer be set. The 5-day window after resolution has expired."),
+ )
+ return redirect("complaints:complaint_detail", pk=pk)
+
satisfaction = request.POST.get("satisfaction", "")
valid_choices = ["satisfied", "neutral", "dissatisfied", "no_response"]
if satisfaction and satisfaction not in valid_choices:
messages.error(request, _("Invalid satisfaction value."))
return redirect("complaints:complaint_detail", pk=pk)
+ old_satisfaction = complaint.satisfaction
+
+ # Only real satisfaction ratings (satisfied/neutral/dissatisfied) consume
+ # the change budget. "no_response" does NOT count.
+ counts_as_change = satisfaction in ("satisfied", "neutral", "dissatisfied")
+ is_new_rating = satisfaction != old_satisfaction
+
+ if counts_as_change and is_new_rating and complaint.satisfaction_change_count >= Complaint.MAX_SATISFACTION_CHANGES:
+ messages.error(
+ request,
+ _("Satisfaction has been changed {} times and can no longer be modified by PX-team. "
+ "Only the patient can override it via the tracking page.").format(Complaint.MAX_SATISFACTION_CHANGES),
+ )
+ return redirect("complaints:complaint_detail", pk=pk)
+
from django.utils import timezone
complaint.satisfaction = satisfaction
if satisfaction:
complaint.satisfaction_set_at = timezone.now()
+ complaint.satisfaction_set_by = request.user
else:
complaint.satisfaction_set_at = None
- complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"])
+ complaint.satisfaction_set_by = None
+
+ # Increment counter only for real ratings that represent a change
+ if counts_as_change and is_new_rating:
+ complaint.satisfaction_change_count += 1
+
+ complaint.save(update_fields=[
+ "satisfaction", "satisfaction_set_at", "satisfaction_set_by",
+ "satisfaction_change_count", "updated_at",
+ ])
+
+ # Log with transition (from → to)
+ SATISFACTION_LABELS = {
+ "satisfied": _("Satisfied"), "neutral": _("Neutral"),
+ "dissatisfied": _("Dissatisfied"), "no_response": _("No Response"),
+ }
+ if is_new_rating and satisfaction:
+ new_label = SATISFACTION_LABELS.get(satisfaction, satisfaction)
+ if old_satisfaction:
+ old_label = str(SATISFACTION_LABELS.get(old_satisfaction, old_satisfaction))
+ log_msg = f"Satisfaction changed from '{old_label}' to '{new_label}' by {request.user.get_full_name()}"
+ else:
+ log_msg = f"Satisfaction set to '{new_label}' by {request.user.get_full_name()}"
+ ComplaintUpdate.objects.create(
+ complaint=complaint,
+ update_type="note",
+ message=log_msg,
+ created_by=request.user,
+ )
+ elif not satisfaction and old_satisfaction:
+ ComplaintUpdate.objects.create(
+ complaint=complaint,
+ update_type="note",
+ message=f"Satisfaction cleared by {request.user.get_full_name()}",
+ created_by=request.user,
+ )
if satisfaction:
messages.success(request, _("Satisfaction updated to: {}").format(complaint.get_satisfaction_display()))
@@ -1907,11 +2034,10 @@ def complaint_escalate(request, pk):
escalate_to_name = escalate_to_staff.get_full_name()
- # Mark as escalated and assign to selected user
+ # Mark as escalated — do NOT reassign. The complaint stays with the
+ # PX-team user who activated it. The escalated person is notified via email.
complaint.escalated_at = timezone.now()
- if escalate_to_user:
- complaint.assigned_to = escalate_to_user
- complaint.save(update_fields=["escalated_at", "assigned_to"])
+ complaint.save(update_fields=["escalated_at"])
# Create update with escalation details
escalation_message = f"Complaint escalated. Reason: {reason}"
@@ -1951,7 +2077,7 @@ def complaint_escalate(request, pk):
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 ""
+ complaint_url = f"https://{request.get_host()}/organizations/departments/{complaint.department.pk}/complaints/{complaint.pk}/" if complaint.department else ""
html_message = f"""
{get_email_header_html()}
@@ -1966,7 +2092,7 @@ def complaint_escalate(request, pk):
Status: {complaint.get_status_display()}
Please review and take appropriate action.
- View Department
+ View Complaint
@@ -2011,6 +2137,31 @@ def complaint_activate(request, pk):
return redirect("complaints:complaint_detail", pk=pk)
+@login_required
+@require_http_methods(["POST"])
+def confirm_taxonomy(request, pk):
+ """Confirm that the taxonomy classification has been reviewed.
+
+ Sets taxonomy_reviewed_at/by so the workflow stepper advances past
+ the 'Review Taxonomy' step, unblocking 'Send to Department'.
+ The taxonomy itself remains editable afterwards via the Edit pencil.
+ """
+ from django.utils import timezone
+
+ 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 confirm taxonomy for this complaint."))
+ return redirect("complaints:complaint_detail", pk=pk)
+
+ complaint.taxonomy_reviewed_at = timezone.now()
+ complaint.taxonomy_reviewed_by = request.user
+ complaint.save(update_fields=["taxonomy_reviewed_at", "taxonomy_reviewed_by", "updated_at"])
+
+ messages.success(request, _("Taxonomy confirmed. You can now send to department."))
+ return redirect("complaints:complaint_detail", pk=pk)
+
+
@login_required
def complaint_export_csv(request):
"""Export complaints to CSV"""
@@ -2580,7 +2731,13 @@ def inquiry_detail(request, pk):
# Get departments for the inquiry's hospital
hospital_departments = []
if inquiry.hospital:
- hospital_departments = Department.objects.filter(hospital=inquiry.hospital, status="active").order_by("name")
+ from django.db.models import Q
+
+ hospital_departments = Department.objects.filter(
+ hospital=inquiry.hospital, status="active",
+ ).filter(
+ Q(champion__isnull=False) | Q(manager__isnull=False)
+ ).order_by("name")
# Status choices for the form
status_choices = [
@@ -2714,6 +2871,19 @@ def inquiry_detail(request, pk):
context["notes"] = inquiry.notes.select_related("created_by").all()
context["notes_count"] = context["notes"].count()
+ # Workflow stepper
+ _iq_steps = [
+ {"label": "Created", "icon": "plus-circle", "done": True},
+ {"label": "Assigned", "icon": "user-plus", "done": inquiry.assigned_at is not None},
+ {"label": "Sent to Dept", "icon": "send", "done": inquiry.sent_to_department_at is not None},
+ {"label": "Dept Response", "icon": "message-square", "done": inquiry.department_responded_at is not None},
+ {"label": "Resolved", "icon": "check-circle-2", "done": inquiry.status in ("resolved", "closed")},
+ ]
+ _iq_next = next((i for i, s in enumerate(_iq_steps) if not s["done"]), len(_iq_steps))
+ if _iq_next < len(_iq_steps):
+ _iq_steps[_iq_next]["current"] = True
+ context["workflow_steps"] = _iq_steps
+
return render(request, "complaints/inquiry_detail.html", context)
@@ -3392,20 +3562,6 @@ def inquiry_respond(request, pk):
return redirect("inquiries:inquiry_detail", pk=pk)
-@login_required
-@require_http_methods(["POST"])
-def inquiry_update_satisfaction(request, pk):
- """Update inquiry satisfaction."""
- inquiry = get_object_or_404(Inquiry, pk=pk)
- satisfaction = request.POST.get("satisfaction", "").strip()
- if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
- inquiry.satisfaction = satisfaction
- inquiry.satisfaction_set_at = timezone.now()
- inquiry.save(update_fields=["satisfaction", "satisfaction_set_at"])
- messages.success(request, _("Satisfaction updated."))
- return redirect("inquiries:inquiry_detail", pk=pk)
-
-
@login_required
@require_http_methods(["POST"])
def inquiry_transfer_to_department(request, pk):
@@ -4502,6 +4658,23 @@ def public_complaint_submit(request):
)
+def public_complaint_pdf(request, reference_number):
+ """Serve the latest PDF report for a complaint (no auth — scanned from QR code).
+
+ Finds the most recent ComplaintPdfSummary for the complaint and serves the
+ saved PDF file inline so the browser displays it directly.
+ """
+ from django.http import Http404, HttpResponse
+
+ complaint = get_object_or_404(Complaint, reference_number__iexact=reference_number)
+ summary = complaint.pdf_summaries.order_by("-created_at").first()
+ if not summary or not summary.file:
+ raise Http404("No PDF report available for this complaint.")
+ response = HttpResponse(summary.file, content_type="application/pdf")
+ response["Content-Disposition"] = f'inline; filename="{complaint.reference_number}.pdf"'
+ return response
+
+
def public_complaint_track(request):
"""
Public complaint tracking page.
@@ -6114,6 +6287,73 @@ def involved_department_review_response(request, pk):
return redirect("complaints:complaint_detail", pk=complaint.pk)
+@login_required
+@require_http_methods(["POST"])
+def involved_department_reject_routing(request, pk):
+ """
+ Champion / department manager rejects the routing of a complaint to their
+ department as 'wrong department'. Only the involved department's routing is
+ affected; if it was the primary department, complaint.department is cleared.
+ """
+ from .models import ComplaintInvolvedDepartment
+ from apps.complaints.services.complaint_service import (
+ reject_department_routing,
+ RoutingRejectionError,
+ )
+ from apps.organizations.models import Department
+
+ involved_dept = get_object_or_404(ComplaintInvolvedDepartment, pk=pk)
+ complaint = involved_dept.complaint
+ department = involved_dept.department
+ user = request.user
+
+ # Permission: champion or manager of this department, the assigned handler,
+ # or anyone with complaint management rights.
+ is_dept_champion = user.is_champion() and user.department == department
+ is_dept_manager = user.is_department_manager() and user.department == department
+ can_act = (
+ is_dept_champion
+ or is_dept_manager
+ or involved_dept.assigned_to == user
+ or can_manage_complaint(user, complaint)
+ )
+ if not can_act:
+ messages.error(request, _("You don't have permission to reject this routing."))
+ return redirect("complaints:complaint_detail", pk=complaint.pk)
+
+ reason = request.POST.get("rejection_reason", "").strip()
+ suggested_id = request.POST.get("suggested_department_id", "").strip()
+ suggested = None
+ if suggested_id:
+ suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
+
+ if not reason:
+ messages.error(request, _("Please provide a reason for rejecting this routing."))
+ return redirect("complaints:complaint_detail", pk=complaint.pk)
+
+ staff = getattr(user, "staff_profile", None)
+ try:
+ reject_department_routing(
+ involved_dept,
+ staff=staff,
+ user=user,
+ reason=reason,
+ suggested_department=suggested,
+ )
+ except RoutingRejectionError as exc:
+ messages.error(
+ request,
+ str(exc) if str(exc) else _("This routing can no longer be rejected."),
+ )
+ return redirect("complaints:complaint_detail", pk=complaint.pk)
+
+ messages.success(
+ request,
+ _(f"Routing to {department.get_localized_name()} rejected. The PX team has been notified."),
+ )
+ return redirect("organizations:department_detail", pk=department.pk)
+
+
@login_required
@require_http_methods(["GET", "POST"])
def involved_staff_add(request, complaint_pk):
diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py
index b2f2a8f..1608875 100644
--- a/apps/complaints/urls.py
+++ b/apps/complaints/urls.py
@@ -15,6 +15,7 @@ from .views import (
staff_investigation_form,
champion_review_answers,
generate_complaint_pdf,
+ generate_complaint_pdf_v2,
inquiry_pdf,
api_locations,
api_sections,
@@ -60,6 +61,7 @@ urlpatterns = [
path("/add-note/", ui_views.complaint_add_note, name="complaint_add_note"),
path("/escalate/", ui_views.complaint_escalate, name="complaint_escalate"),
path("/activate/", ui_views.complaint_activate, name="complaint_activate"),
+ path("/confirm-taxonomy/", ui_views.confirm_taxonomy, name="confirm_taxonomy"),
# Export Views
path("export/csv/", ui_views.complaint_export_csv, name="complaint_export_csv"),
path("export/excel/", ui_views.complaint_export_excel, name="complaint_export_excel"),
@@ -125,6 +127,7 @@ urlpatterns = [
# Public Complaint Form (No Authentication Required)
path("public/submit/", ui_views.public_complaint_submit, name="public_complaint_submit"),
path("public/track/", ui_views.public_complaint_track, name="public_complaint_track"),
+ path("public/pdf//", ui_views.public_complaint_pdf, name="public_complaint_pdf"),
path("public/success//", ui_views.public_complaint_success, name="public_complaint_success"),
path("public/api/lookup-patient/", ui_views.api_lookup_patient, name="api_lookup_patient"),
path("public/api/load-departments/", ui_views.api_load_departments, name="api_load_departments"),
@@ -166,6 +169,7 @@ urlpatterns = [
),
# PDF Export
path("/pdf/", generate_complaint_pdf, name="complaint_pdf"),
+ path("/pdf-v2/", generate_complaint_pdf_v2, name="complaint_pdf_v2"),
path("inquiries//pdf/", inquiry_pdf, name="inquiry_pdf"),
# Involved Departments Management
path("/departments/add/", ui_views.involved_department_add, name="involved_department_add"),
@@ -178,6 +182,7 @@ urlpatterns = [
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"),
+ path("departments//reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"),
# Unified Send To (Person or Department) - AJAX
path("/send-to/", ui_views.complaint_send_to, name="complaint_send_to"),
# Collect Feedback (champion/manager compose questions for staff)
diff --git a/apps/complaints/urls_inquiries.py b/apps/complaints/urls_inquiries.py
index 8ce6dfc..efc9da4 100644
--- a/apps/complaints/urls_inquiries.py
+++ b/apps/complaints/urls_inquiries.py
@@ -17,7 +17,6 @@ urlpatterns = [
path("/reopen/", ui_views.inquiry_reopen, name="inquiry_reopen"),
path("/add-note/", ui_views.inquiry_add_note, name="inquiry_add_note"),
path("/respond/", ui_views.inquiry_respond, name="inquiry_respond"),
- path("/update-satisfaction/", ui_views.inquiry_update_satisfaction, name="inquiry_update_satisfaction"),
path("/transfer-to-department/", ui_views.inquiry_transfer_to_department, name="inquiry_transfer_to_department"),
path("/department-response/", ui_views.inquiry_department_response, name="inquiry_department_response"),
path("/review-dept-response/", ui_views.inquiry_review_dept_response, name="inquiry_review_dept_response"),
diff --git a/apps/complaints/views.py b/apps/complaints/views.py
index 32b02e1..e71df24 100644
--- a/apps/complaints/views.py
+++ b/apps/complaints/views.py
@@ -1945,6 +1945,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
try:
from apps.core.ai_service import AIService
+ from django.utils import timezone
import json
explanations_text = ""
@@ -1952,7 +1953,7 @@ The matter has been addressed through appropriate channels. Appropriate measures
name = exp.staff.get_full_name() if exp.staff else "Staff"
explanations_text += f"\n- {name}: {exp.explanation[:500]}"
- prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for resolving it.
+ prompt = f"""You are a patient experience resolution advisor. Based on this complaint, provide actionable suggestions for responding to the patient.
Complaint: {complaint.description[:1500]}
Severity: {complaint.get_severity_display()}
@@ -1961,10 +1962,20 @@ Priority: {complaint.get_priority_display()}
Status: {complaint.get_status_display()}
Staff explanations received: {explanations_text or 'None yet'}
-Generate a JSON response with:
-- "suggestions": array of 3-5 actionable suggestions, each with "title" and "description"
-- "recommended_actions": array of immediate next steps
-- "communication_tip": a tip for communicating with the patient"""
+IMPORTANT: ALL text fields must be provided in BOTH English and Arabic.
+
+Generate a JSON response with this exact structure:
+{{
+ "suggestions": [
+ {{"title_en": "Short title", "title_ar": "عنوان قصير", "description_en": "Description", "description_ar": "الوصف"}}
+ ],
+ "recommended_actions_en": ["Action 1", "Action 2"],
+ "recommended_actions_ar": ["الإجراء 1", "الإجراء 2"],
+ "communication_tip_en": "Tip text",
+ "communication_tip_ar": "نصيحة"
+}}
+
+Provide 3-5 suggestions. Keep them concise and focused on how to respond to the patient."""
result = AIService.chat_completion(
prompt=prompt,
@@ -1972,7 +1983,12 @@ Generate a JSON response with:
)
parsed = json.loads(result)
- return Response({"success": True, "suggestions": parsed})
+ # Persist to complaint
+ complaint.ai_response_suggestions = parsed
+ complaint.ai_response_suggestions_at = timezone.now()
+ complaint.save(update_fields=["ai_response_suggestions", "ai_response_suggestions_at", "updated_at"])
+
+ return Response({"success": True})
except Exception as e:
logger.error(f"AI helper suggestion failed: {e}")
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -2221,12 +2237,54 @@ Generate JSON with exactly these fields:
import io
from PIL import Image as PILImage
+
+ def _img_to_data_uri(path):
+ try:
+ img = PILImage.open(path)
+ buf = io.BytesIO()
+ img.save(buf, format="PNG", optimize=True)
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+ except Exception:
+ return None
+
_logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.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()
+ # Per-hospital letterhead + stamp (with static fallback)
+ letterhead_path = None
+ if complaint.hospital and complaint.hospital.letterhead:
+ letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
+ if not letterhead_path:
+ letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
+
+ stamp_path = None
+ if complaint.hospital and complaint.hospital.stamp:
+ stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
+ if not stamp_path:
+ stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
+
+ # QR code encoding the public tracking URL for authenticity verification
+ qr_code_path = None
+ try:
+ import qrcode
+ from django.urls import reverse
+
+ pdf_url = request.build_absolute_uri(
+ reverse("complaints:public_complaint_pdf", kwargs={"reference_number": complaint.reference_number})
+ )
+ qr = qrcode.QRCode(version=1, box_size=4, border=1)
+ qr.add_data(pdf_url)
+ qr.make(fit=True)
+ qr_img = qr.make_image(fill_color="black", back_color="white")
+ qr_buf = io.BytesIO()
+ qr_img.save(qr_buf, format="PNG")
+ qr_code_path = "data:image/png;base64," + base64.b64encode(qr_buf.getvalue()).decode()
+ except Exception:
+ pass
+
html_string = render_to_string(
"complaints/complaint_summary_pdf.html",
{
@@ -2235,6 +2293,9 @@ Generate JSON with exactly these fields:
"content_summary": content_summary,
"dept_response_summary": dept_response_summary,
"logo_path": logo_path,
+ "letterhead_path": letterhead_path,
+ "stamp_path": stamp_path,
+ "qr_code_path": qr_code_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 "",
@@ -3816,6 +3877,18 @@ def complaint_review_pdf(request, complaint_id, token):
return response
+def _hospital_departments(complaint):
+ """Active departments in the complaint's hospital (for the suggest-dept dropdown)."""
+ from apps.organizations.models import Department
+ from django.db.models import Q
+
+ if not complaint.hospital:
+ return Department.objects.none()
+ return Department.objects.filter(
+ hospital=complaint.hospital, status="active",
+ ).filter(Q(champion__isnull=False) | Q(manager__isnull=False)).order_by("name")
+
+
def complaint_explanation_form(request, complaint_id, token):
"""
Public-facing form for staff to submit explanation.
@@ -3889,6 +3962,7 @@ def complaint_explanation_form(request, complaint_id, token):
"token": explanation.token,
})
),
+ "hospital_departments": _hospital_departments(complaint),
}
# --- Step 1: Send verification code ---
@@ -3975,6 +4049,19 @@ def complaint_explanation_form(request, complaint_id, token):
"otp_email": "email" in sent_channels,
})
+ # --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
+ if action == "cancel_otp":
+ investigation = ChampionInvestigation.objects.filter(
+ explanation=explanation,
+ status=InvestigationStatus.DIRECT_REPLY_IN_PROGRESS,
+ ).first()
+ if investigation:
+ investigation.otp_code = ""
+ investigation.otp_sent_at = None
+ investigation.save(update_fields=["otp_code", "otp_sent_at"])
+ # Re-render with otp_sent=False; base_ctx preserves all form values
+ return render(request, "complaints/explanation_form.html", {**base_ctx})
+
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
@@ -4047,11 +4134,14 @@ def complaint_explanation_form(request, complaint_id, token):
file_size=uploaded_file.size,
)
- # Mark ALL other explanations for this complaint as used (first responder wins)
- ComplaintExplanation.objects.filter(
- complaint=complaint,
- is_used=False,
- ).update(is_used=True)
+ # First-responder-wins WITHIN the same department only.
+ # Other departments' explanations stay valid (parallel collection).
+ if explanation.staff and explanation.staff.department_id:
+ ComplaintExplanation.objects.filter(
+ complaint=complaint,
+ is_used=False,
+ staff__department_id=explanation.staff.department_id,
+ ).exclude(pk=explanation.pk).update(is_used=True)
# Update the linked ComplaintInvolvedDepartment
from apps.complaints.models import ComplaintInvolvedDepartment
@@ -4134,6 +4224,69 @@ def complaint_explanation_form(request, complaint_id, token):
{"complaint": complaint, "explanation": explanation, "attachment_count": len(files)},
)
+ # --- Reject routing (wrong department) ---
+ if action == "reject_routing":
+ from apps.complaints.services.complaint_service import (
+ reject_department_routing,
+ RoutingRejectionError,
+ )
+ from apps.complaints.models import ComplaintInvolvedDepartment
+ from apps.organizations.models import Department
+
+ reason = request.POST.get("rejection_reason", "").strip()
+ suggested_id = request.POST.get("suggested_department_id", "").strip()
+ suggested = None
+ if suggested_id:
+ suggested = Department.objects.filter(id=suggested_id, hospital=complaint.hospital).first()
+
+ if not reason:
+ return render(request, "complaints/explanation_form.html", {
+ **base_ctx,
+ "show_reject_form": True,
+ "error": _("Please provide a reason for rejecting this routing."),
+ })
+
+ # Resolve the involved department this champion belongs to
+ involved_dept = None
+ if explanation.staff and explanation.staff.department_id:
+ involved_dept = ComplaintInvolvedDepartment.objects.filter(
+ complaint=complaint, department_id=explanation.staff.department_id,
+ ).first()
+ if not involved_dept:
+ involved_dept = ComplaintInvolvedDepartment.objects.filter(
+ complaint=complaint, sent=True,
+ ).first()
+
+ if not involved_dept:
+ return render(request, "complaints/explanation_form.html", {
+ **base_ctx, "show_reject_form": True,
+ "error": _("Could not find the department routing to reject."),
+ })
+
+ try:
+ reject_department_routing(
+ involved_dept,
+ staff=explanation.staff,
+ reason=reason,
+ suggested_department=suggested,
+ )
+ except RoutingRejectionError as exc:
+ return render(request, "complaints/explanation_form.html", {
+ **base_ctx, "show_reject_form": True,
+ "error": str(exc) if str(exc) else _("This routing can no longer be rejected."),
+ })
+
+ # Consume the token so it can't be reused
+ explanation.is_used = True
+ explanation.responded_at = timezone.now()
+ explanation.save(update_fields=["is_used", "responded_at"])
+
+ return render(
+ request,
+ "complaints/explanation_routing_rejected.html",
+ {"complaint": complaint, "explanation": explanation, "involved_dept": involved_dept},
+ )
+
# Unknown / missing action — show form with error
return render(request, "complaints/explanation_form.html", {
**base_ctx,
@@ -4178,6 +4331,7 @@ def complaint_explanation_form(request, complaint_id, token):
"accused_staff": accused_staff,
"existing_investigation": existing_investigation,
"otp_sent": otp_sent_on_get,
+ "hospital_departments": _hospital_departments(complaint),
"investigate_url": request.build_absolute_uri(
reverse("complaints:champion_start_investigation", kwargs={
"complaint_id": complaint.id,
@@ -4498,6 +4652,8 @@ def staff_search_for_investigation(request, complaint_id, token):
| Q(name__icontains=q)
| Q(name_ar__icontains=q)
| Q(employee_id__icontains=q)
+ | Q(civil_id__icontains=q)
+ | Q(license_number__icontains=q)
)
.select_related("department")[:20]
)
@@ -4507,6 +4663,7 @@ def staff_search_for_investigation(request, complaint_id, token):
"id": str(s.id),
"name": s.get_full_name(),
"department_name": s.department.get_localized_name() if s.department else "",
+ "employee_id": s.employee_id or "",
"already_added": s.id in already_linked,
}
for s in qs
@@ -4810,6 +4967,15 @@ def champion_review_answers(request, complaint_id, token):
"otp_email": "email" in sent_channels,
})
+ # --- Cancel OTP: user clicked "Edit response" — re-enable the form ---
+ if action == "cancel_otp":
+ investigation.otp_code = ""
+ investigation.otp_sent_at = None
+ investigation.save(update_fields=["otp_code", "otp_sent_at"])
+ return render(request, "complaints/investigation_review.html", {
+ **base_ctx, "final_reply": final_reply, "consent_checked": True,
+ })
+
# --- Step 2: Verify code + submit ---
if action == "verify_submit":
entered_code = request.POST.get("otp_code", "").strip()
@@ -4874,10 +5040,14 @@ def champion_review_answers(request, complaint_id, token):
file_size=f.size,
)
- ComplaintExplanation.objects.filter(
- complaint=complaint,
- is_used=False,
- ).update(is_used=True)
+ # First-responder-wins WITHIN the same department only.
+ # Other departments' explanations stay valid (parallel collection).
+ if explanation.staff and explanation.staff.department_id:
+ ComplaintExplanation.objects.filter(
+ complaint=complaint,
+ is_used=False,
+ staff__department_id=explanation.staff.department_id,
+ ).exclude(pk=explanation.pk).update(is_used=True)
involved_dept = investigation.involved_department or explanation.linked_involved_department
if involved_dept:
@@ -5069,6 +5239,112 @@ def generate_complaint_pdf(request, pk):
return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
+def generate_complaint_pdf_v2(request, pk):
+ """Generate complaint PDF using the Artboard 1 PNG letterhead (v2)."""
+ complaint = get_object_or_404(Complaint, id=pk)
+
+ if not request.user.is_authenticated:
+ return HttpResponse("Unauthorized", status=401)
+
+ if not (
+ request.user.is_px_admin()
+ or (request.user.is_hospital_admin() and request.user.hospital == complaint.hospital)
+ or (request.user.is_department_manager() and request.user.department == complaint.department)
+ or (request.user.hospital == complaint.hospital)
+ ):
+ return HttpResponse("Forbidden", status=403)
+
+ from django.template.loader import render_to_string
+
+ import io
+ import base64
+ from PIL import Image as PILImage
+
+ logo_path = None
+ try:
+ logo_img = PILImage.open(settings.BASE_DIR / "static" / "img" / "HH_P_ICON.png")
+ logo_img.thumbnail((600, 600), PILImage.LANCZOS)
+ _buf = io.BytesIO()
+ logo_img.save(_buf, format="PNG", optimize=True)
+ logo_path = "data:image/png;base64," + base64.b64encode(_buf.getvalue()).decode()
+ except Exception:
+ pass
+
+ def _img_to_data_uri(path):
+ """Open an image file and return it as a base64 data URI."""
+ try:
+ img = PILImage.open(path)
+ buf = io.BytesIO()
+ img.save(buf, format="PNG", optimize=True)
+ return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
+ except Exception:
+ return None
+
+ # Letterhead: hospital-specific upload → static fallback
+ letterhead_path = None
+ if complaint.hospital and complaint.hospital.letterhead:
+ letterhead_path = _img_to_data_uri(complaint.hospital.letterhead.path)
+ if not letterhead_path:
+ letterhead_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "artboard" / "Artboard 1@3x.png")
+
+ # Stamp: hospital-specific upload → static fallback
+ stamp_path = None
+ if complaint.hospital and complaint.hospital.stamp:
+ stamp_path = _img_to_data_uri(complaint.hospital.stamp.path)
+ if not stamp_path:
+ stamp_path = _img_to_data_uri(settings.BASE_DIR / "static" / "images" / "stamps" / "stamp.png")
+
+ explanations = complaint.explanations.all().select_related("staff", "accepted_by").prefetch_related("attachments")
+ timeline = complaint.updates.all().select_related("created_by")[:20]
+
+ from apps.px_action_center.models import PXAction
+ from django.contrib.contenttypes.models import ContentType
+
+ 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")[:5]
+
+ html_string = render_to_string(
+ "complaints/complaint_pdf_v2.html",
+ {
+ "complaint": complaint,
+ "explanations": explanations,
+ "timeline": timeline,
+ "px_actions": px_actions,
+ "generated_at": timezone.now(),
+ "logo_path": logo_path,
+ "letterhead_path": letterhead_path,
+ "stamp_path": stamp_path,
+ },
+ )
+
+ try:
+ from weasyprint import HTML
+
+ pdf_file = HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
+
+ response = HttpResponse(pdf_file, content_type="application/pdf")
+ response["X-Frame-Options"] = "SAMEORIGIN"
+
+ view_mode = request.GET.get("view", "download")
+ if view_mode == "inline":
+ response["Content-Disposition"] = "inline"
+ else:
+ from datetime import datetime
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ filename = f"complaint_{complaint.reference_number}_{timestamp}.pdf"
+ response["Content-Disposition"] = f'attachment; filename="{filename}"'
+
+ return response
+ except ImportError:
+ return HttpResponse("WeasyPrint is not installed.", status=500)
+ except Exception as e:
+ import logging
+
+ logging.getLogger(__name__).error(f"Error generating v2 PDF for complaint {pk}: {e}")
+ return HttpResponse(f"Error generating PDF: {str(e)}", status=500)
+
+
def inquiry_explanation_form(request, inquiry_id, token):
"""
Public-facing form for staff to submit response to an inquiry.
diff --git a/apps/core/management/commands/seed_e2e_project.py b/apps/core/management/commands/seed_e2e_project.py
index c7e370e..337652a 100644
--- a/apps/core/management/commands/seed_e2e_project.py
+++ b/apps/core/management/commands/seed_e2e_project.py
@@ -1,6 +1,7 @@
"""
Test-only helper: seed a QI project in E2E-HOSP with PDCA+FOCUS phases,
-team members from 2 different departments, and tasks assigned to them.
+two linked departments (whose champion + manager form the cross-dept team),
+and tasks assigned to those team members.
Usage:
manage.py seed_e2e_project
@@ -24,7 +25,7 @@ class Command(BaseCommand):
e2e = Hospital.objects.get(code="E2E-HOSP")
depts = list(Department.objects.filter(hospital=e2e))
- dept_a = depts[0] # Contact Center (champion's dept)
+ dept_a = depts[0] # Contact Center (staff_a's dept)
dept_b = depts[1] if len(depts) > 1 else depts[0] # a different dept
# Ensure Staff profiles exist for e2e-staff (dept_a) and e2e-nurse (dept_b)
@@ -49,16 +50,24 @@ class Command(BaseCommand):
if staff_b.department_id != dept_b.id:
staff_b.department = dept_b; staff_b.save(update_fields=["department"])
+ # Make staff_a the champion of dept_a and staff_b the champion of dept_b,
+ # so they become derived team members of the project (ACL requires it).
+ if dept_a.champion_id != staff_a.id:
+ dept_a.champion = staff_a; dept_a.save(update_fields=["champion"])
+ if dept_b.champion_id != staff_b.id:
+ dept_b.champion = staff_b; dept_b.save(update_fields=["champion"])
+
n = QIProject.objects.count()
project = QIProject.objects.create(
hospital=e2e,
- department=dept_a,
name=f"E2E QI Project #{n}",
description=f"E2E cross-department QI project #{n}. Automated - please ignore.",
status="pending",
focus_enabled=True,
)
- project.team_members.add(staff_a, staff_b)
+ # Link both departments; the m2m_changed signal derives the team
+ # (champions staff_a + staff_b) automatically.
+ project.departments.set([dept_a, dept_b])
# Create PDCA phases
plan_phase = None
diff --git a/apps/core/reference.py b/apps/core/reference.py
index 7bd95c6..01e3d69 100644
--- a/apps/core/reference.py
+++ b/apps/core/reference.py
@@ -1,41 +1,43 @@
"""
Unified reference-number generator.
-Format: {PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ:04d}
- e.g. CMP-202606-HHN-0001
+Format: {PREFIX}-{YYYYMM}-{SEQ:04d}
+ e.g. CMP-202607-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)
+ SEQ 4-digit monthly sequence (global across all hospitals)
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.
+concurrent submissions. Legacy references keep their old format
+({PREFIX}-{YYYYMM}-{HOSPITAL_TOKEN}-{SEQ}) — 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."""
+ """Derive a clean, format-safe token from a hospital instance.
+
+ Kept for backward compatibility but no longer used in the output format.
+ """
code = getattr(hospital, "code", None) if hospital else None
if not code:
return "GEN"
- token = _TOKEN_RE.sub("", str(code).upper())
+ token = re.sub(r"[^A-Z0-9]", "", str(code).upper())
return token or "GEN"
def generate_reference(prefix: str, hospital) -> str:
- """Generate a unified reference number for the given module prefix."""
+ """Generate a unified reference number for the given module prefix.
+
+ Format: CMP-202607-0001 (no hospital token in the output).
+ All hospitals share a single global sequence per (prefix, month).
+ """
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}"
+ number = ReferenceSequence.next_number(prefix, "GLOBAL", year_month)
+ return f"{prefix}-{year_month}-{number:04d}"
diff --git a/apps/core/views.py b/apps/core/views.py
index 6e40602..24a69ff 100644
--- a/apps/core/views.py
+++ b/apps/core/views.py
@@ -585,7 +585,6 @@ def _track_inquiry(reference):
"en": inquiry.response_en or inquiry.response or "",
"ar": inquiry.response_ar or "",
},
- "satisfaction": inquiry.satisfaction or "",
})
@@ -630,7 +629,6 @@ def _track_observation(reference):
"en": observation.response_en or observation.response or "",
"ar": observation.response_ar or "",
},
- "satisfaction": observation.satisfaction or "",
})
@@ -784,14 +782,9 @@ def public_set_satisfaction(request):
if upper.startswith("CMP-"):
from apps.complaints.models import Complaint
obj = Complaint.objects.get(reference_number__iexact=reference)
- elif upper.startswith("INQ-"):
- from apps.complaints.models import Inquiry
- obj = Inquiry.objects.get(reference_number__iexact=reference)
- elif upper.startswith("OBS-"):
- from apps.observations.models import Observation
- obj = Observation.objects.get(tracking_code__iexact=reference)
else:
- return JsonResponse({"success": False, "error": "Unrecognized reference format."}, status=400)
+ # Satisfaction is only collected for complaints.
+ return JsonResponse({"success": False, "error": "Satisfaction is only available for complaints."}, status=400)
except Exception:
return JsonResponse({"success": False, "error": "Not found."}, status=404)
diff --git a/apps/feedback/views.py b/apps/feedback/views.py
index ccf11b7..a0c1a29 100644
--- a/apps/feedback/views.py
+++ b/apps/feedback/views.py
@@ -274,6 +274,19 @@ def feedback_detail(request, pk):
"notes_count": generic_notes.count(),
}
+ # Workflow stepper
+ _fb_steps = [
+ {"label": "Submitted", "icon": "plus-circle", "done": True},
+ {"label": "Assigned", "icon": "user-plus", "done": feedback.assigned_to is not None},
+ {"label": "Reviewed", "icon": "eye", "done": feedback.status in ("reviewed", "acknowledged", "closed")},
+ {"label": "Acknowledged", "icon": "thumbs-up", "done": feedback.status in ("acknowledged", "closed")},
+ {"label": "Closed", "icon": "check-circle-2", "done": feedback.status == "closed"},
+ ]
+ _fb_next = next((i for i, s in enumerate(_fb_steps) if not s["done"]), len(_fb_steps))
+ if _fb_next < len(_fb_steps):
+ _fb_steps[_fb_next]["current"] = True
+ context["workflow_steps"] = _fb_steps
+
return render(request, "feedback/feedback_detail.html", context)
diff --git a/apps/observations/migrations/0019_remove_inquiry_observation_satisfaction.py b/apps/observations/migrations/0019_remove_inquiry_observation_satisfaction.py
new file mode 100644
index 0000000..d00d0e8
--- /dev/null
+++ b/apps/observations/migrations/0019_remove_inquiry_observation_satisfaction.py
@@ -0,0 +1,21 @@
+# Generated by Django 6.0.1 on 2026-07-04 19:55
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('observations', '0018_observation_satisfaction_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='observation',
+ name='satisfaction',
+ ),
+ migrations.RemoveField(
+ model_name='observation',
+ name='satisfaction_set_at',
+ ),
+ ]
diff --git a/apps/observations/models.py b/apps/observations/models.py
index 66d22c0..bbf14da 100644
--- a/apps/observations/models.py
+++ b/apps/observations/models.py
@@ -598,13 +598,6 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel):
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="responded_observations"
)
- # Satisfaction
- satisfaction = models.CharField(
- max_length=20, blank=True, default="",
- choices=[("satisfied", "Satisfied"), ("neutral", "Neutral"), ("dissatisfied", "Dissatisfied"), ("no_response", "No Response")],
- )
- satisfaction_set_at = models.DateTimeField(null=True, blank=True)
-
# Closure
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(
diff --git a/apps/observations/urls.py b/apps/observations/urls.py
index 9b0ffad..87f9be2 100644
--- a/apps/observations/urls.py
+++ b/apps/observations/urls.py
@@ -57,7 +57,6 @@ urlpatterns = [
path("/note/", views.observation_add_note, name="observation_add_note"),
# Respond (patient-facing response)
path("/respond/", views.observation_respond, name="observation_respond"),
- path("/update-satisfaction/", views.observation_update_satisfaction, name="observation_update_satisfaction"),
path("/generate-ai-response/", views.observation_generate_ai_response, name="observation_generate_ai_response"),
# Send to Department
path("/send-to-department/", views.observation_send_to_department, name="observation_send_to_department"),
diff --git a/apps/observations/views.py b/apps/observations/views.py
index 664891a..1b7d296 100644
--- a/apps/observations/views.py
+++ b/apps/observations/views.py
@@ -702,6 +702,19 @@ def observation_detail(request, pk):
context["escalation_email_subject"] = escalation_email_subject
context["escalation_email_body"] = escalation_email_body
+ # Workflow stepper
+ _obs_steps = [
+ {"label": "Created", "icon": "plus-circle", "done": True},
+ {"label": "Assigned", "icon": "user-plus", "done": observation.assigned_at is not None},
+ {"label": "Sent to Dept", "icon": "send", "done": observation.forwarded_to_dept_at is not None},
+ {"label": "Dept Response", "icon": "message-square", "done": bool(observation.department_response_en or observation.department_response_ar)},
+ {"label": "Resolved", "icon": "check-circle-2", "done": observation.status in ("resolved", "closed")},
+ ]
+ _obs_next = next((i for i, s in enumerate(_obs_steps) if not s["done"]), len(_obs_steps))
+ if _obs_next < len(_obs_steps):
+ _obs_steps[_obs_next]["current"] = True
+ context["workflow_steps"] = _obs_steps
+
return render(request, "observations/observation_detail.html", context)
@@ -1089,20 +1102,6 @@ Generate comprehensive, professional responses in both languages. Use Modern Sta
return JsonResponse({"success": False, "error": f"Failed to generate response: {str(e)}"}, status=500)
-@login_required
-@require_http_methods(["POST"])
-def observation_update_satisfaction(request, pk):
- """Update observation satisfaction."""
- observation = get_object_or_404(Observation, pk=pk)
- satisfaction = request.POST.get("satisfaction", "").strip()
- if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
- observation.satisfaction = satisfaction
- observation.satisfaction_set_at = timezone.now()
- observation.save(update_fields=["satisfaction", "satisfaction_set_at"])
- messages.success(request, _("Satisfaction updated."))
- return redirect("observations:observation_detail", pk=pk)
-
-
@login_required
@require_http_methods(["GET", "POST"])
def observation_convert_to_action(request, pk):
diff --git a/apps/organizations/admin.py b/apps/organizations/admin.py
index f7e9b2c..d33159e 100644
--- a/apps/organizations/admin.py
+++ b/apps/organizations/admin.py
@@ -54,6 +54,7 @@ class HospitalAdmin(admin.ModelAdmin):
("Contact Information", {"fields": ("address", "city", "phone", "email")}),
("Executive Leadership", {"fields": ("ceo", "medical_director", "coo", "cfo")}),
("Details", {"fields": ("license_number", "capacity", "status")}),
+ ("Branding", {"fields": ("letterhead", "stamp")}),
("Metadata", {"fields": ("created_at", "updated_at")}),
)
autocomplete_fields = ["organization", "ceo", "medical_director", "coo", "cfo"]
diff --git a/apps/organizations/migrations/0015_hospital_branding.py b/apps/organizations/migrations/0015_hospital_branding.py
new file mode 100644
index 0000000..5f3197e
--- /dev/null
+++ b/apps/organizations/migrations/0015_hospital_branding.py
@@ -0,0 +1,23 @@
+# Generated by Django 6.0.1 on 2026-07-01 17:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('organizations', '0014_remove_department_manager_1st'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='hospital',
+ name='letterhead',
+ field=models.ImageField(blank=True, help_text='A4 transparent PNG with hospital header/footer artwork for PDF letterhead', null=True, upload_to='hospital_branding/'),
+ ),
+ migrations.AddField(
+ model_name='hospital',
+ name='stamp',
+ field=models.ImageField(blank=True, help_text='Transparent stamp PNG placed at bottom-right of PDF pages', null=True, upload_to='hospital_branding/'),
+ ),
+ ]
diff --git a/apps/organizations/models.py b/apps/organizations/models.py
index 7371f58..85cbd0a 100644
--- a/apps/organizations/models.py
+++ b/apps/organizations/models.py
@@ -84,6 +84,20 @@ class Hospital(UUIDModel, TimeStampedModel):
# Status
status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE, db_index=True)
+ # Branding — per-hospital PDF letterhead and stamp images
+ letterhead = models.ImageField(
+ upload_to="hospital_branding/",
+ blank=True,
+ null=True,
+ help_text="A4 transparent PNG with hospital header/footer artwork for PDF letterhead",
+ )
+ stamp = models.ImageField(
+ upload_to="hospital_branding/",
+ blank=True,
+ null=True,
+ help_text="Transparent stamp PNG placed at bottom-right of PDF pages",
+ )
+
# Executive leadership
ceo = models.ForeignKey(
"accounts.User",
diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py
index 30644b1..7cf79ef 100644
--- a/apps/organizations/ui_views.py
+++ b/apps/organizations/ui_views.py
@@ -2248,6 +2248,17 @@ def department_detail(request, pk):
status__in=["open", "in_progress"],
involved_departments__department=department,
).distinct().select_related("department", "assigned_to")[:5],
+ "pending_routing_involvements": ComplaintInvolvedDepartment.objects.filter(
+ department=department,
+ sent=True,
+ response_submitted=False,
+ routing_status="sent",
+ ).select_related("complaint", "complaint__department", "department").order_by("-sent_at")[:10],
+ "hospital_departments": (
+ Department.objects.filter(hospital=department.hospital, status="active")
+ .filter(Q(champion__isnull=False) | Q(manager__isnull=False))
+ .order_by("name")
+ ),
}
return render(request, "organizations/department_detail.html", context)
@@ -2256,6 +2267,8 @@ def _check_department_access(user, department):
return True
if user.is_hospital_admin() and user.hospital == department.hospital:
return True
+ if department.manager_id == user.id:
+ return True
if user.is_champion() and user.department == department:
return True
if user.is_department_manager() and user.department == department:
diff --git a/apps/projects/admin.py b/apps/projects/admin.py
index 73c5585..f9821c1 100644
--- a/apps/projects/admin.py
+++ b/apps/projects/admin.py
@@ -18,21 +18,21 @@ class QIProjectTaskInline(admin.TabularInline):
class QIProjectAdmin(admin.ModelAdmin):
"""QI Project admin"""
list_display = [
- 'name', 'hospital', 'department', 'project_lead',
+ 'name', 'hospital', 'get_departments', 'project_lead',
'status', 'start_date', 'target_completion_date'
]
- list_filter = ['status', 'hospital', 'department', 'start_date']
+ list_filter = ['status', 'hospital', 'departments', 'start_date']
search_fields = ['name', 'name_ar', 'description']
ordering = ['-created_at']
inlines = [QIProjectTaskInline]
- filter_horizontal = ['team_members', 'related_actions']
-
+ filter_horizontal = ['team_members', 'related_actions', 'departments']
+
fieldsets = (
(None, {
'fields': ('name', 'name_ar', 'description')
}),
('Organization', {
- 'fields': ('hospital', 'department')
+ 'fields': ('hospital', 'departments')
}),
('Team', {
'fields': ('project_lead', 'team_members')
@@ -53,12 +53,19 @@ class QIProjectAdmin(admin.ModelAdmin):
'classes': ('collapse',)
}),
)
-
+
readonly_fields = ['created_at', 'updated_at']
-
+
def get_queryset(self, request):
qs = super().get_queryset(request)
- return qs.select_related('hospital', 'department', 'project_lead')
+ return qs.select_related('hospital', 'project_lead').prefetch_related('departments')
+
+ @admin.display(description='Departments')
+ def get_departments(self, obj):
+ depts = list(obj.departments.all())
+ if not depts:
+ return "—"
+ return ", ".join(d.name for d in depts)
@admin.register(QIProjectTask)
diff --git a/apps/projects/export_utils.py b/apps/projects/export_utils.py
index 844ab21..fb4d347 100644
--- a/apps/projects/export_utils.py
+++ b/apps/projects/export_utils.py
@@ -61,7 +61,7 @@ def export_project_excel(project):
info_data = [
("Description", project.description),
("Hospital", project.hospital.name if project.hospital else ""),
- ("Department", project.department.name if project.department else ""),
+ ("Department", ", ".join(d.name for d in project.departments.all()) if project.departments.exists() else ""),
("Project Lead", project.project_lead.get_full_name() if project.project_lead else ""),
("Created By", project.created_by.get_full_name() if project.created_by else ""),
("Status", project.get_status_display()),
diff --git a/apps/projects/forms.py b/apps/projects/forms.py
index e6d862f..31827d0 100644
--- a/apps/projects/forms.py
+++ b/apps/projects/forms.py
@@ -8,11 +8,11 @@ from django import forms
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _
-from apps.core.form_mixins import HospitalFieldMixin
from apps.accounts.models import User
-from apps.organizations.models import Department, Hospital
+from apps.core.form_mixins import HospitalFieldMixin
+from apps.organizations.models import Department
-from .models import QIProject, QIProjectTask, PDCAPhase, FOCUSPhase
+from .models import FOCUSPhase, PDCAPhase, QIProject, QIProjectTask
class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
@@ -31,9 +31,8 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"name_ar",
"description",
"hospital",
- "department",
+ "departments",
"project_lead",
- "team_members",
"status",
"start_date",
"target_completion_date",
@@ -65,18 +64,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
- "department": forms.Select(
- attrs={
- "class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
- }
- ),
- "project_lead": forms.Select(
+ "departments": forms.SelectMultiple(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
+ "size": "6",
}
),
- "team_members": forms.SelectMultiple(
+ "project_lead": forms.Select(
attrs={
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
"data-tomselect": "",
@@ -127,21 +122,25 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.user.hospital.id
if hospital_id:
- self.fields["department"].queryset = Department.objects.filter(
+ self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
- # Filter staff choices based on hospital
+ # Filter project lead based on hospital
from apps.organizations.models import Staff
staff_qs = Staff.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("first_name", "last_name")
self.fields["project_lead"].queryset = staff_qs
- self.fields["team_members"].queryset = staff_qs
else:
- self.fields["department"].queryset = Department.objects.none()
- self.fields["project_lead"].queryset = Staff.objects.none() if False else []
- self.fields["team_members"].queryset = []
+ self.fields["departments"].queryset = Department.objects.none()
+ self.fields["project_lead"].queryset = Department.objects.none()
+
+ def save(self, *args, **kwargs):
+ instance = super().save(*args, **kwargs)
+ # Team membership is derived from the selected departments' champion + manager.
+ instance.sync_team_members_from_departments()
+ return instance
class QIProjectTaskForm(forms.ModelForm):
@@ -244,11 +243,12 @@ class QIProjectTaskForm(forms.ModelForm):
self.fields["focus_phase"].queryset = FOCUSPhase.objects.none()
self.fields["focus_phase"].widget = forms.HiddenInput()
- # Filter assigned_to choices based on project hospital
- if self.project and self.project.hospital:
+ # Filter assigned_to choices based on project team members
+ # (derived from each department's champion + manager, plus the project lead).
+ if self.project and self.project.pk:
from apps.organizations.models import Staff
self.fields["assigned_to"].queryset = Staff.objects.filter(
- hospital=self.project.hospital, status="active"
+ pk__in=self.project.get_team_members_staff_ids()
).order_by("first_name", "last_name")
else:
self.fields["assigned_to"].queryset = User.objects.none()
@@ -269,7 +269,7 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
class Meta:
model = QIProject
- fields = ["name", "name_ar", "description", "hospital", "department", "target_completion_date"]
+ fields = ["name", "name_ar", "description", "hospital", "departments", "target_completion_date"]
widgets = {
"name": forms.TextInput(
attrs={
@@ -296,9 +296,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
"class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
}
),
- "department": forms.Select(
+ "departments": forms.SelectMultiple(
attrs={
- "class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white"
+ "class": "w-full px-4 py-2.5 rounded-xl border border-slate-200 focus:border-navy focus:ring-2 focus:ring-navy/20 transition text-sm bg-white",
+ "data-tomselect": "",
+ "size": "6",
}
),
"target_completion_date": forms.DateInput(
@@ -332,11 +334,11 @@ class QIProjectTemplateForm(HospitalFieldMixin, forms.ModelForm):
hospital_id = self.user.hospital.id
if hospital_id:
- self.fields["department"].queryset = Department.objects.filter(
+ self.fields["departments"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
else:
- self.fields["department"].queryset = Department.objects.none()
+ self.fields["departments"].queryset = Department.objects.none()
class ConvertToProjectForm(forms.Form):
diff --git a/apps/projects/migrations/0004_qi_multi_department.py b/apps/projects/migrations/0004_qi_multi_department.py
new file mode 100644
index 0000000..660d8b6
--- /dev/null
+++ b/apps/projects/migrations/0004_qi_multi_department.py
@@ -0,0 +1,81 @@
+# Generated for QI multi-department support.
+#
+# Order matters:
+# 1. Add the new `departments` M2M (blank).
+# 2. Copy each existing `department_id` into the M2M, then rebuild the derived
+# `team_members` set from each department's champion + manager (and the
+# project lead). The sync logic is inlined here (not imported from the
+# model) so the migration stays stable as the model method evolves.
+# 3. Remove the old single `department` FK.
+
+from django.db import migrations, models
+
+
+def copy_department_and_sync_team(apps, schema_editor):
+ QIProject = apps.get_model("projects", "QIProject")
+ Staff = apps.get_model("organizations", "Staff")
+
+ missing_manager_profile = 0
+ for project in QIProject.objects.exclude(department__isnull=True):
+ old_dept = project.department
+ # M2M add via the through manager (project has just been saved with the new field).
+ if old_dept and old_dept not in project.departments.all():
+ project.departments.add(old_dept)
+
+ # Rebuild derived team_members: champion (Staff) + manager.staff_profile (Staff),
+ # plus the project lead. De-duped; nulls skipped.
+ pks = set()
+ for dept in project.departments.all():
+ champion_id = getattr(dept, "champion_id", None)
+ if champion_id:
+ pks.add(champion_id)
+ manager_user_id = getattr(dept, "manager_id", None)
+ if manager_user_id:
+ manager_user = dept.manager
+ if manager_user is not None and hasattr(manager_user, "staff_profile"):
+ sp = manager_user.staff_profile
+ if sp is not None and sp.id:
+ pks.add(sp.id)
+ else:
+ missing_manager_profile += 1
+ if project.project_lead_id:
+ pks.add(project.project_lead_id)
+
+ if pks:
+ project.team_members.set(Staff.objects.filter(pk__in=pks))
+
+ if missing_manager_profile:
+ # Surface how many manager links could not be added (no staff_profile).
+ print(f" [qi_multi_department] {missing_manager_profile} department manager(s) "
+ f"had no staff_profile and were excluded from the derived team.")
+
+
+def remove_team_backreference(apps, schema_editor):
+ # Reverse is a no-op: we cannot reconstruct the single FK from the M2M losslessly.
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("organizations", "0015_hospital_branding"),
+ ("projects", "0003_alter_qiproject_project_lead_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="qiproject",
+ name="departments",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
+ related_name="qi_projects",
+ to="organizations.department",
+ ),
+ ),
+ migrations.RunPython(copy_department_and_sync_team, remove_team_backreference),
+ migrations.RemoveField(
+ model_name="qiproject",
+ name="department",
+ ),
+ ]
diff --git a/apps/projects/models.py b/apps/projects/models.py
index 03417ec..acff25b 100644
--- a/apps/projects/models.py
+++ b/apps/projects/models.py
@@ -56,8 +56,11 @@ class QIProject(UUIDModel, TimeStampedModel):
blank=True,
help_text="Null for global templates available to all hospitals",
)
- department = models.ForeignKey(
- "organizations.Department", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_projects"
+ departments = models.ManyToManyField(
+ "organizations.Department",
+ blank=True,
+ related_name="qi_projects",
+ help_text="Departments involved in this QI project. Team membership is derived from each department's champion and manager.",
)
# Project lead
@@ -112,6 +115,44 @@ class QIProject(UUIDModel, TimeStampedModel):
return f"[Template] {self.name}"
return f"{self.name} ({self.status})"
+ def get_team_members_staff_ids(self):
+ """Return the set of Staff PKs derived from each linked department's
+ champion (a Staff) and manager (a User -> staff_profile). De-duped; nulls skipped."""
+ pks = set()
+ for dept in self.departments.all():
+ if dept.champion_id:
+ pks.add(dept.champion_id)
+ # `Department.manager` is a FK to User; resolve to its Staff profile if present.
+ manager_user_id = getattr(dept, "manager_id", None)
+ if manager_user_id:
+ manager_user = dept.manager
+ if manager_user is not None and hasattr(manager_user, "staff_profile"):
+ sp = manager_user.staff_profile
+ if sp is not None and sp.id:
+ pks.add(sp.id)
+ # Ensure project lead is always considered part of the team
+ if self.project_lead_id:
+ pks.add(self.project_lead_id)
+ return pks
+
+ def sync_team_members_from_departments(self):
+ """Rebuild the `team_members` M2M from the champion + manager of each
+ linked department (plus the project lead). Idempotent."""
+ if not self.pk:
+ return
+ from apps.organizations.models import Staff
+
+ self.team_members.set(Staff.objects.filter(pk__in=self.get_team_members_staff_ids()))
+
+ def is_team_member(self, user):
+ """True if the given user's staff_profile is a derived team member or the project lead."""
+ staff_profile = getattr(user, "staff_profile", None)
+ if not staff_profile:
+ return False
+ if self.project_lead_id and self.project_lead_id == staff_profile.id:
+ return True
+ return self.team_members.filter(pk=staff_profile.id).exists()
+
class QIProjectTask(UUIDModel, TimeStampedModel):
"""
diff --git a/apps/projects/signals.py b/apps/projects/signals.py
index bfde4c5..7071c6a 100644
--- a/apps/projects/signals.py
+++ b/apps/projects/signals.py
@@ -1,12 +1,18 @@
"""
-Signals for QI Projects — sends a notification when a task is assigned.
+Signals for QI Projects.
+
+- Sends a notification when a task is assigned.
+- Keeps `team_members` in sync with the champion + manager of each linked
+ department whenever the `departments` M2M changes.
"""
import logging
-from django.db.models.signals import post_save
+from django.db.models.signals import m2m_changed, post_save
from django.dispatch import receiver
+from apps.projects.models import QIProject
+
logger = logging.getLogger(__name__)
@@ -36,3 +42,20 @@ def notify_task_assignment(sender, instance, created, **kwargs):
)
except Exception as e:
logger.warning(f"Failed to send QI task notification: {e}")
+
+
+@receiver(m2m_changed, sender=QIProject.departments.through)
+def sync_team_on_departments_change(sender, instance, action, **kwargs):
+ """Rebuild the derived `team_members` set whenever the project's departments change.
+
+ Fires on `post_add` / `post_remove` / `post_clear` so that programmatic,
+ admin, and form-driven edits all stay consistent. The sync is idempotent.
+ """
+ if action not in ("post_add", "post_remove", "post_clear"):
+ return
+ if not instance.pk:
+ return
+ try:
+ instance.sync_team_members_from_departments()
+ except Exception as e: # defensive: never break an M2M write on sync failure
+ logger.warning(f"Failed to sync QI team members for project {instance.pk}: {e}")
diff --git a/apps/projects/tests.py b/apps/projects/tests.py
new file mode 100644
index 0000000..14a9f33
--- /dev/null
+++ b/apps/projects/tests.py
@@ -0,0 +1,157 @@
+"""
+Tests for the QI Projects app.
+
+Covers the multi-department + derived-team behaviour:
+ - Team membership is derived from each department's champion + manager (+ lead).
+ - Sync fires on M2M changes to `departments`.
+ - ACL: only admins / project lead / team members can view & manage.
+ - Task `assigned_to` is limited to derived team members.
+"""
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+from django.urls import reverse
+
+from apps.organizations.models import Department, Hospital, Staff
+from apps.projects.models import QIProject, QIProjectTask
+
+User = get_user_model()
+
+
+def _make_staff(hospital, department=None, first="Staff", email=None):
+ return Staff.objects.create(
+ hospital=hospital,
+ department=department,
+ first_name=first,
+ last_name="Test",
+ status="active",
+ staff_type="other",
+ job_title="T",
+ employee_id=f"EMP-{first}-{Hospital.objects.count()}",
+ user=User.objects.create_user(
+ username=email or f"{first.lower()}@test.local",
+ email=email or f"{first.lower()}@test.local",
+ password="pw",
+ ),
+ )
+
+
+class DerivedTeamTests(TestCase):
+ """sync_team_members_from_departments + m2m_changed signal."""
+
+ def setUp(self):
+ self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
+ self.dept_a = Department.objects.create(hospital=self.hospital, name="Dept A", code="DA", status="active")
+ self.dept_b = Department.objects.create(hospital=self.hospital, name="Dept B", code="DB", status="active")
+ self.champ_a = _make_staff(self.hospital, self.dept_a, "ChampA")
+ self.champ_b = _make_staff(self.hospital, self.dept_b, "ChampB")
+ self.dept_a.champion = self.champ_a
+ self.dept_a.save()
+ self.dept_b.champion = self.champ_b
+ self.dept_b.save()
+ self.lead = _make_staff(self.hospital, self.dept_a, "Lead")
+
+ def _new_project(self, **kw):
+ defaults = dict(
+ name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
+ )
+ defaults.update(kw)
+ return QIProject.objects.create(**defaults)
+
+ def test_team_derived_from_department_champions(self):
+ p = self._new_project()
+ p.departments.set([self.dept_a, self.dept_b])
+ # champ_a, champ_b (champions) + lead (project_lead) → 3 members
+ self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
+ {self.champ_a.pk, self.champ_b.pk, self.lead.pk})
+
+ def test_manager_without_staff_profile_is_skipped(self):
+ # Manager is a User with no staff_profile → excluded
+ mgr_user = User.objects.create_user(username="mgr", email="mgr@x", password="p")
+ self.dept_a.manager = mgr_user
+ self.dept_a.save()
+ p = self._new_project()
+ p.departments.set([self.dept_a])
+ # Only champ_a + lead; mgr_user has no staff_profile so not added
+ self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
+ {self.champ_a.pk, self.lead.pk})
+
+ def test_manager_with_staff_profile_is_included(self):
+ mgr_staff = _make_staff(self.hospital, self.dept_a, "Mgr")
+ mgr_user = mgr_staff.user
+ self.dept_a.manager = mgr_user
+ self.dept_a.save()
+ p = self._new_project()
+ p.departments.set([self.dept_a])
+ self.assertIn(mgr_staff.pk, set(p.team_members.values_list("pk", flat=True)))
+
+ def test_removing_department_rebuilds_team(self):
+ p = self._new_project()
+ p.departments.set([self.dept_a, self.dept_b])
+ self.assertIn(self.champ_b.pk, set(p.team_members.values_list("pk", flat=True)))
+ p.departments.remove(self.dept_b)
+ # champ_b no longer in team; champ_a + lead remain
+ self.assertEqual(set(p.team_members.values_list("pk", flat=True)),
+ {self.champ_a.pk, self.lead.pk})
+
+ def test_is_team_member_helper(self):
+ p = self._new_project()
+ p.departments.set([self.dept_a])
+ self.assertTrue(p.is_team_member(self.champ_a.user))
+ self.assertTrue(p.is_team_member(self.lead.user))
+ self.assertFalse(p.is_team_member(self.champ_b.user))
+
+
+class ACLTests(TestCase):
+ """_check_project_permission + _get_can_edit via the views."""
+
+ def setUp(self):
+ self.hospital = Hospital.objects.create(name="H1", code="H1", status="active")
+ self.hospital2 = Hospital.objects.create(name="H2", code="H2", status="active")
+ self.dept = Department.objects.create(hospital=self.hospital, name="D", code="D", status="active")
+ self.champ = _make_staff(self.hospital, self.dept, "Champ")
+ self.outsider = _make_staff(self.hospital, self.dept, "Outsider")
+ self.dept.champion = self.champ
+ self.dept.save()
+ self.lead = _make_staff(self.hospital, self.dept, "Lead")
+ self.project = QIProject.objects.create(
+ name="P", description="d", hospital=self.hospital, status="pending", project_lead=self.lead
+ )
+ self.project.departments.set([self.dept]) # derives team = {champ, lead}
+
+ def test_team_member_can_view_detail(self):
+ # ACL helper level (view-level rendering is blocked by a pre-existing
+ # staticfiles-manifest issue in the test env, unrelated to this feature).
+ from apps.projects.ui_views import _check_project_permission, _get_can_edit
+
+ self.assertTrue(_check_project_permission(self.project, self.champ.user))
+ self.assertFalse(_get_can_edit(self.champ.user, self.project)) # not lead/admin
+
+ def test_non_team_same_hospital_user_is_denied(self):
+ from apps.projects.ui_views import _check_project_permission
+
+ # Outsider is same-hospital but NOT a team member → denied
+ self.assertFalse(_check_project_permission(self.project, self.outsider.user))
+
+ def test_project_lead_can_edit(self):
+ from apps.projects.ui_views import _get_can_edit
+
+ # Lead is not an admin but is the project lead → can edit
+ self.assertTrue(_get_can_edit(self.lead.user, self.project))
+
+ def test_task_assigned_to_is_limited_to_team_members(self):
+ from apps.projects.forms import QIProjectTaskForm
+
+ form = QIProjectTaskForm(project=self.project)
+ qs_ids = set(form.fields["assigned_to"].queryset.values_list("pk", flat=True))
+ self.assertEqual(qs_ids, {self.champ.pk, self.lead.pk})
+ self.assertNotIn(self.outsider.pk, qs_ids)
+
+ def test_task_toggle_requires_post(self):
+ task = QIProjectTask.objects.create(project=self.project, title="t", assigned_to=self.champ, status="pending")
+ self.client.force_login(self.champ.user)
+ url = reverse("projects:task_toggle_status",
+ kwargs={"project_pk": self.project.pk, "task_pk": task.pk})
+ # GET must be rejected (405) — CSRF-safe toggle
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 405)
diff --git a/apps/projects/ui_views.py b/apps/projects/ui_views.py
index ec7863c..11a37a3 100644
--- a/apps/projects/ui_views.py
+++ b/apps/projects/ui_views.py
@@ -12,6 +12,7 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
+from django.views.decorators.http import require_POST
from apps.core.decorators import block_source_user
from apps.core.models import StatusChoices
@@ -258,8 +259,8 @@ def project_list(request):
# Exclude templates from the list
queryset = (
QIProject.objects.filter(is_template=False)
- .select_related("hospital", "department", "project_lead", "project_lead__department")
- .prefetch_related("team_members", "related_actions")
+ .select_related("hospital", "project_lead", "project_lead__department")
+ .prefetch_related("departments", "team_members", "related_actions")
)
# Apply RBAC filters
@@ -271,8 +272,24 @@ def project_list(request):
# PX Admins see all, but filter by selected hospital if set
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
+ elif user.is_hospital_admin():
+ # Hospital admins see projects in their hospital, scoped by team membership
+ if user.hospital:
+ queryset = queryset.filter(hospital=user.hospital)
+ staff_profile = getattr(user, "staff_profile", None)
+ if staff_profile:
+ queryset = queryset.filter(
+ Q(team_members=staff_profile) | Q(project_lead=staff_profile)
+ ).distinct()
elif user.hospital:
- queryset = queryset.filter(hospital=user.hospital)
+ # Other users: same hospital AND must be a team member or project lead
+ staff_profile = getattr(user, "staff_profile", None)
+ if staff_profile:
+ queryset = queryset.filter(hospital=user.hospital).filter(
+ Q(team_members=staff_profile) | Q(project_lead=staff_profile)
+ ).distinct()
+ else:
+ queryset = queryset.none()
# Apply filters
status_filter = request.GET.get("status")
@@ -283,6 +300,10 @@ def project_list(request):
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
+ department_filter = request.GET.get("department")
+ if department_filter:
+ queryset = queryset.filter(departments__id=department_filter).distinct()
+
# Search
search_query = request.GET.get("search")
if search_query:
@@ -332,14 +353,17 @@ def project_detail(request, pk):
project = get_object_or_404(
QIProject.objects.filter(is_template=False)
- .select_related("hospital", "department", "project_lead", "project_lead__department")
- .prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"),
+ .select_related("hospital", "project_lead", "project_lead__department")
+ .prefetch_related(
+ "departments", "team_members", "related_actions", "tasks",
+ "pdca_phases", "focus_phases",
+ ),
pk=pk,
)
- # Check permission
+ # Check permission (admins, project lead, or team members)
user = request.user
- if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
+ if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to view this project."))
return redirect("projects:project_list")
@@ -412,7 +436,7 @@ def project_create(request, template_pk=None):
"name": template.name,
"name_ar": template.name_ar,
"description": template.description,
- "department": template.department,
+ "departments": template.departments.all(),
"target_completion_date": template.target_completion_date,
}
if not user.is_px_admin() and user.hospital:
@@ -453,7 +477,7 @@ def project_create(request, template_pk=None):
if user.is_px_admin():
initial_data["hospital"] = obj.hospital_id
if hasattr(obj, "department_id") and obj.department_id:
- initial_data["department"] = obj.department_id
+ initial_data["departments"] = [obj.department_id]
except Exception:
pass
@@ -569,11 +593,11 @@ def project_delete(request, pk):
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can delete)
- if not (user.is_px_admin() or user.is_hospital_admin):
+ if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to delete projects."))
return redirect("projects:project_detail", pk=project.pk)
- if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
+ if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to delete this project."))
return redirect("projects:project_list")
@@ -598,12 +622,12 @@ def project_save_as_template(request, pk):
project = get_object_or_404(QIProject, pk=pk, is_template=False)
# Check permission (only PX Admin or Hospital Admin can create templates)
- if not (user.is_px_admin() or user.is_hospital_admin):
+ if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_detail", pk=project.pk)
# Check hospital access
- if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
+ if not _check_project_permission(project, user):
messages.error(request, _("You don't have permission to create templates from this project."))
return redirect("projects:project_list")
@@ -624,10 +648,11 @@ def project_save_as_template(request, pk):
is_template=True,
# If global, hospital is None; otherwise use project's hospital
hospital=None if make_global else project.hospital,
- department=project.department,
status="pending", # Default status for templates
created_by=user,
)
+ # Copy linked departments from project to template
+ template.departments.set(project.departments.all())
# Copy tasks from project to template
for task in project.tasks.all():
@@ -683,8 +708,8 @@ def task_create(request, project_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
- # Check permission — admin only
- if not _get_can_edit(user):
+ # Check permission — must have project access + edit rights
+ if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to add tasks to this project."))
return redirect("projects:project_detail", pk=project.pk)
@@ -726,8 +751,8 @@ def task_edit(request, project_pk, task_pk, phase=None):
current_phase = get_object_or_404(FOCUSPhase, phase=phase, project=project)
phase_type = "focus"
- # Check permission — admin only
- if not _get_can_edit(user):
+ # Check permission — must have project access + edit rights
+ if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to edit tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@@ -766,8 +791,8 @@ def task_delete(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)
- # Check permission — admin only
- if not _get_can_edit(user):
+ # Check permission — must have project access + edit rights
+ if not _check_project_permission(project, user) or not _get_can_edit(user, project):
messages.error(request, _("You don't have permission to delete tasks in this project."))
return redirect("projects:project_detail", pk=project.pk)
@@ -790,21 +815,17 @@ def task_delete(request, project_pk, task_pk, phase=None):
@block_source_user
@login_required
+@require_POST
def task_toggle_status(request, project_pk, task_pk, phase=None):
"""Quick toggle task status between pending and completed"""
user = request.user
project = get_object_or_404(QIProject, pk=project_pk, is_template=False)
task = get_object_or_404(QIProjectTask, pk=task_pk, project=project)
- if not _can_manage_task(project, task, user):
+ if not _check_project_permission(project, user) or not _can_manage_task(project, task, user):
messages.error(request, _("You don't have permission to update task status."))
return redirect("projects:project_detail", pk=project.pk)
- # Check hospital access
- if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
- messages.error(request, _("You don't have permission to update tasks in this project."))
- return redirect("projects:project_detail", pk=project.pk)
-
from django.utils import timezone
if task.status == "completed":
@@ -831,16 +852,14 @@ def template_list(request):
user = request.user
# Only admins can manage templates
- if not (user.is_px_admin() or user.is_hospital_admin):
+ if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
- queryset = QIProject.objects.filter(is_template=True).select_related("hospital", "department")
+ queryset = QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments")
# Apply RBAC filters
if not user.is_px_admin():
- from django.db.models import Q
-
queryset = queryset.filter(Q(hospital=user.hospital) | Q(hospital__isnull=True))
# Search
@@ -856,7 +875,7 @@ def template_list(request):
context = {
"templates": queryset,
- "can_create": user.is_px_admin() or user.is_hospital_admin,
+ "can_create": user.is_px_admin() or user.is_hospital_admin(),
"can_edit": user.is_px_admin() or user.is_hospital_admin(),
}
@@ -870,18 +889,17 @@ def template_detail(request, pk):
user = request.user
# Only admins can view templates
- if not (user.is_px_admin() or user.is_hospital_admin):
+ if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to view templates."))
return redirect("projects:project_list")
template = get_object_or_404(
- QIProject.objects.filter(is_template=True).select_related("hospital", "department"), pk=pk
+ QIProject.objects.filter(is_template=True).select_related("hospital").prefetch_related("departments"),
+ pk=pk,
)
# Check permission for hospital-specific templates
if not user.is_px_admin():
- from django.db.models import Q
-
if template.hospital and template.hospital != user.hospital:
messages.error(request, _("You don't have permission to view this template."))
return redirect("projects:template_list")
@@ -904,7 +922,7 @@ def template_create(request):
user = request.user
# Only admins can create templates
- if not (user.is_px_admin() or user.is_hospital_admin):
+ if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, _("You don't have permission to create templates."))
return redirect("projects:project_list")
@@ -1036,13 +1054,14 @@ def convert_action_to_project(request, action_pk):
name_ar=template.name_ar,
description=template.description,
hospital=action.hospital,
- department=template.department,
project_lead=form.cleaned_data["project_lead"],
target_completion_date=form.cleaned_data["target_completion_date"],
status="pending",
created_by=user,
)
- # Copy tasks from template
+ # Copy linked departments + tasks from template
+ project.departments.set(template.departments.all())
+ project.sync_team_members_from_departments()
for template_task in template.tasks.all():
QIProjectTask.objects.create(
project=project,
@@ -1062,6 +1081,8 @@ def convert_action_to_project(request, action_pk):
status="pending",
created_by=user,
)
+ # Sync team from project lead (no departments in blank conversion)
+ project.sync_team_members_from_departments()
# Link to the action
project.related_actions.add(action)
@@ -1177,16 +1198,13 @@ def pdca_phase_edit(request, pk, phase):
from apps.accounts.models import User
team_members = project.team_members.all()
- if project.project_lead:
- if project.project_lead not in team_members:
- team_members = list(team_members) + [project.project_lead]
context = {
"project": project,
"pdca_phase": pdca_phase,
"phase_key": phase,
"phase_label": PDCAPhaseChoices(phase).label,
- "team_members": team_members.distinct(),
+ "team_members": team_members,
"status_choices": StatusChoices.choices,
}
@@ -1290,16 +1308,13 @@ def focus_phase_edit(request, pk, phase):
from apps.accounts.models import User
team_members = project.team_members.all()
- if project.project_lead:
- if project.project_lead not in team_members:
- team_members = list(team_members) + [project.project_lead]
context = {
"project": project,
"focus_phase": focus_phase,
"phase_key": phase,
"phase_label": FOCUSPhaseChoices(phase).label,
- "team_members": team_members.distinct(),
+ "team_members": team_members,
"status_choices": StatusChoices.choices,
}
@@ -1335,23 +1350,39 @@ def project_export_excel(request, pk):
def _check_project_permission(project, user):
- """Helper to check if user can access project."""
- if not user.is_px_admin() and user.hospital and project.hospital != user.hospital:
+ """Check if user can access (view) a project.
+
+ Granted to: PX admins, hospital admins (same hospital), the project lead,
+ or any derived team member (champion/manager of a linked department).
+ """
+ if user.is_px_admin():
+ return True
+ # Hospital admins are scoped to their own hospital
+ if user.is_hospital_admin():
+ return not (user.hospital and project.hospital and user.hospital != project.hospital)
+ # Cross-hospital access is always denied for regular users
+ if user.hospital and project.hospital and user.hospital != project.hospital:
return False
- return True
+ return project.is_team_member(user)
-def _get_can_edit(user):
- """Helper to check edit permissions — admin only."""
- return user.is_px_admin() or user.is_hospital_admin()
+def _get_can_edit(user, project):
+ """Check edit (structural) permissions: admins of the same hospital, or the project lead."""
+ if user.is_px_admin():
+ return True
+ if user.is_hospital_admin():
+ return not (user.hospital and project.hospital and user.hospital != project.hospital)
+ staff_profile = getattr(user, "staff_profile", None)
+ return bool(staff_profile and project.project_lead_id == staff_profile.id)
def _can_manage_task(project, task, user):
"""Check if user can toggle a task status.
- True for admins, or the task's assignee (so they can check/uncheck their own tasks).
+ True for admins (same hospital), the project lead, or the task's assignee
+ (so they can check/uncheck their own tasks).
"""
- if _get_can_edit(user):
+ if _get_can_edit(user, project):
return True
staff_profile = getattr(user, "staff_profile", None)
if staff_profile and task.assigned_to_id == staff_profile.id:
@@ -1361,6 +1392,7 @@ def _can_manage_task(project, task, user):
@block_source_user
@login_required
+@require_POST
def htmx_task_toggle_status(request, project_pk, task_pk):
"""Toggle task status via HTMX - returns updated task row"""
from django.utils import timezone
@@ -1385,7 +1417,7 @@ def htmx_task_toggle_status(request, project_pk, task_pk):
task.save()
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
can_toggle = _can_manage_task(project, task, user)
today = timezone.now().date()
@@ -1409,7 +1441,7 @@ def htmx_task_delete(request, project_pk, task_pk):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- if not _get_can_edit(user):
+ if not _get_can_edit(user, project):
return HttpResponse(_("You don't have permission to delete tasks."), status=403)
# Determine which column to refresh
@@ -1436,7 +1468,7 @@ def htmx_task_create(request, project_pk, phase_type, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to add tasks."), status=403)
@@ -1544,7 +1576,7 @@ def htmx_task_edit_form(request, project_pk, task_pk):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to edit tasks."), status=403)
@@ -1622,7 +1654,7 @@ def htmx_pdca_column(request, project_pk, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
today = timezone.now().date()
phase_obj, _ = PDCAPhase.objects.get_or_create(
@@ -1666,7 +1698,7 @@ def htmx_focus_column(request, project_pk, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
today = timezone.now().date()
phase_obj, _ = FOCUSPhase.objects.get_or_create(
@@ -1708,7 +1740,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
if not _check_project_permission(project, user):
return HttpResponse(_("Permission denied"), status=403)
- can_edit = _get_can_edit(user)
+ can_edit = _get_can_edit(user, project)
if not can_edit:
return HttpResponse(_("You don't have permission to edit phases."), status=403)
@@ -1770,9 +1802,6 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
# GET - return form
team_members = project.team_members.all()
- if project.project_lead:
- if project.project_lead not in team_members:
- team_members = list(team_members) + [project.project_lead]
return render(
request,
@@ -1783,7 +1812,7 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase):
"phase_key": phase,
"phase_label": phase_label,
"phase_type": phase_type,
- "team_members": team_members.distinct(),
+ "team_members": team_members,
"status_choices": StatusChoices.choices,
},
)
diff --git a/apps/px_action_center/ui_views.py b/apps/px_action_center/ui_views.py
index d2b4f5f..cdbda44 100644
--- a/apps/px_action_center/ui_views.py
+++ b/apps/px_action_center/ui_views.py
@@ -244,6 +244,37 @@ def action_detail(request, pk):
return render(request, "actions/action_detail.html", context)
+@login_required
+@require_http_methods(["POST"])
+def action_upload_attachment(request, pk):
+ """Upload an attachment or evidence file to a PX Action."""
+ action = get_object_or_404(PXAction, pk=pk)
+
+ user = request.user
+ if not (user.is_px_admin() or user.is_hospital_admin() or action.assigned_to == user):
+ messages.error(request, "You don't have permission to upload attachments.")
+ return redirect("actions:action_detail", pk=pk)
+
+ uploaded = request.FILES.get("file")
+ if not uploaded:
+ messages.error(request, "Please select a file to upload.")
+ return redirect("actions:action_detail", pk=pk)
+
+ PXActionAttachment.objects.create(
+ action=action,
+ file=uploaded,
+ filename=uploaded.name,
+ file_type=uploaded.content_type,
+ file_size=uploaded.size,
+ uploaded_by=user,
+ description=request.POST.get("description", "").strip(),
+ is_evidence=request.POST.get("is_evidence") == "on",
+ )
+
+ messages.success(request, "File uploaded successfully.")
+ return redirect("actions:action_detail", pk=pk)
+
+
@login_required
@require_http_methods(["POST"])
def action_assign(request, pk):
@@ -474,7 +505,18 @@ def action_create(request):
return redirect("actions:action_list")
if request.method == "POST":
- form = ManualActionForm(request.POST, request=request)
+ post = request.POST.copy()
+ # Inject required fields the template doesn't render as visible inputs
+ if not post.get("source_type"):
+ post["source_type"] = (
+ ActionSource.COMPLAINT if post.get("complaint_id") else ActionSource.MANUAL
+ )
+ if not post.get("hospital"):
+ if user.hospital:
+ post["hospital"] = str(user.hospital.id)
+ elif hasattr(request, "tenant_hospital") and request.tenant_hospital:
+ post["hospital"] = str(request.tenant_hospital.id)
+ form = ManualActionForm(post, request=request)
if form.is_valid():
action = form.save(commit=False)
action.created_by = user
@@ -486,6 +528,17 @@ def action_create(request):
if action.assigned_to:
action.assigned_at = timezone.now()
+ # Link to complaint if provided (from complaint detail → "Create Action" button)
+ complaint_id = request.POST.get("complaint_id", "").strip()
+ if complaint_id:
+ from apps.complaints.models import Complaint
+ from django.contrib.contenttypes.models import ContentType
+ complaint = Complaint.objects.filter(pk=complaint_id).first()
+ if complaint:
+ action.source_type = ActionSource.COMPLAINT
+ action.content_type = ContentType.objects.get_for_model(Complaint)
+ action.object_id = complaint.id
+
action.save()
# Create log
@@ -526,10 +579,19 @@ def action_create(request):
form = ManualActionForm(request=request)
+ # Complaint linkage (from complaint detail → "Create Action" button)
+ linked_complaint_id = request.GET.get("complaint_id", "").strip()
+ linked_complaint = None
+ if linked_complaint_id:
+ from apps.complaints.models import Complaint
+ linked_complaint = Complaint.objects.filter(pk=linked_complaint_id).first()
+
context = {
"form": form,
"source_choices": ActionSource.choices,
"status_choices": ActionStatus.choices,
+ "linked_complaint_id": linked_complaint_id,
+ "linked_complaint": linked_complaint,
}
return render(request, "actions/action_create.html", context)
diff --git a/apps/px_action_center/urls.py b/apps/px_action_center/urls.py
index 8d1b87d..b99f90b 100644
--- a/apps/px_action_center/urls.py
+++ b/apps/px_action_center/urls.py
@@ -23,6 +23,7 @@ urlpatterns = [
),
path("/", ui_views.action_detail, name="action_detail"),
path("/assign/", ui_views.action_assign, name="action_assign"),
+ path("/upload-attachment/", ui_views.action_upload_attachment, name="action_upload_attachment"),
path("/change-status/", ui_views.action_change_status, name="action_change_status"),
path("/add-note/", ui_views.action_add_note, name="action_add_note"),
path("/escalate/", ui_views.action_escalate, name="action_escalate"),
diff --git a/config/settings/base.py b/config/settings/base.py
index 4fabccf..9a08d95 100644
--- a/config/settings/base.py
+++ b/config/settings/base.py
@@ -189,7 +189,7 @@ LOCALE_PATHS = [
# AI-powered translation configuration
DJANGO_AI_PO = {
"MODEL": "openrouter/deepseek/deepseek-v4-flash",
- "API_KEY": env("OPENROUTER_API_KEY"),
+ "API_KEY": env("OPENROUTER_API_KEY", default=""),
"TEMPERATURE": 0.2,
"BATCH_SIZE": 5,
"WORKERS": 2,
@@ -370,9 +370,7 @@ SLA_DEFAULTS = {
COMPLAINT_LINK_EXPIRY_DAYS = env.int("COMPLAINT_LINK_EXPIRY_DAYS", default=7)
# AI Configuration (OpenRouter)
-OPENROUTER_API_KEY = env(
- "OPENROUTER_API_KEY", default="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"
-)
+OPENROUTER_API_KEY = env("OPENROUTER_API_KEY", default="")
AI_MODEL = env("AI_MODEL", default="z-ai/glm-4.5-air:free")
AI_TEMPERATURE = env.float("AI_TEMPERATURE", default=0.3)
AI_MAX_TOKENS = env.int("AI_MAX_TOKENS", default=500)
@@ -445,20 +443,10 @@ EMAIL_LOGO_URL = env("EMAIL_LOGO_URL", default=f"{SITE_URL}{STATIC_URL}img/HH_P_
# HIS Integration Settings
HIS_API_URL = env("HIS_API_URL", default="https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps")
-HIS_API_USERNAME = env("HIS_API_USERNAME", default="AlhhSUNZHippo")
+HIS_API_USERNAME = env("HIS_API_USERNAME", default="")
+HIS_API_PASSWORD = env("HIS_API_PASSWORD", default="")
HIS_RATINGS_API_URL = env("HIS_RATINGS_API_URL", default="")
-# Password contains special characters (*#$@PAlhh^2106) - set directly to avoid parsing issues
-# Check .env first, fall back to hardcoded default
-try:
- _env_password = env("HIS_API_PASSWORD")
- if _env_password and _env_password != "*":
- HIS_API_PASSWORD = _env_password
- else:
- HIS_API_PASSWORD = "*#$@PAlhh^2106"
-except Exception:
- HIS_API_PASSWORD = "*#$@PAlhh^2106"
-
# Security Settings
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
@@ -497,20 +485,14 @@ TENANT_ISOLATION_LEVEL = "strict"
# Social Media API Configuration
-YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="AIzaSyAem20etP6GkRNMmCyI1pRJF7v8U_xDyMM")
-YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="UCKoEfCXsm4_cQMtqJTvZUVQ")
+YOUTUBE_API_KEY = env("YOUTUBE_API_KEY", default="")
+YOUTUBE_CHANNEL_ID = env("YOUTUBE_CHANNEL_ID", default="")
-FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="938104059393026")
-FACEBOOK_ACCESS_TOKEN = env(
- "FACEBOOK_ACCESS_TOKEN",
- default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
-)
+FACEBOOK_PAGE_ID = env("FACEBOOK_PAGE_ID", default="")
+FACEBOOK_ACCESS_TOKEN = env("FACEBOOK_ACCESS_TOKEN", default="")
-INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="17841431861985364")
-INSTAGRAM_ACCESS_TOKEN = env(
- "INSTAGRAM_ACCESS_TOKEN",
- default="EAATrDf0UAS8BQWSKbljCUDMbluZBbxZCSWLJkZBGIviBtK8IQ7FDHfGQZBHHm7lsgLhZBL2trT3ZBGPtsWRjntFWQovhkhx726ZBexRZCKitEMhxAiZBmls7uX946432k963Myl6aYBzJzwLhSyygZAFOGP7iIIZANVf6GtLlvAnWn0NXRwZAYR0CNNUwCEEsZAAc",
-)
+INSTAGRAM_ACCOUNT_ID = env("INSTAGRAM_ACCOUNT_ID", default="")
+INSTAGRAM_ACCESS_TOKEN = env("INSTAGRAM_ACCESS_TOKEN", default="")
# Twitter/X Configuration
TWITTER_BEARER_TOKEN = env("TWITTER_BEARER_TOKEN", default=None)
@@ -526,9 +508,6 @@ GOOGLE_TOKEN_FILE = env("GOOGLE_TOKEN_FILE", default="token.json")
GOOGLE_LOCATIONS = env.list("GOOGLE_LOCATIONS", default=[])
# OpenRouter Configuration for AI Comment Analysis
-OPENROUTER_API_KEY = env(
- "OPENROUTER_API_KEY", default="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"
-)
OPENROUTER_MODEL = env("OPENROUTER_MODEL", default="google/gemma-3-27b-it:free")
ANALYSIS_BATCH_SIZE = env.int("ANALYSIS_BATCH_SIZE", default=2)
ANALYSIS_ENABLED = env.bool("ANALYSIS_ENABLED", default=True)
diff --git a/config/settings/dev.py b/config/settings/dev.py
index dfbd06b..f8c5c8c 100644
--- a/config/settings/dev.py
+++ b/config/settings/dev.py
@@ -6,7 +6,7 @@ from .base import * # noqa
DEBUG = True
-ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
+ALLOWED_HOSTS = ["192.168.8.13","localhost", "127.0.0.1", "0.0.0.0", ".ngrok-free.app", "micha-nonparabolic-lovie.ngrok-free.dev"]
# Database - Use PostgreSQL even in dev for consistency
# Override with SQLite if needed for quick local testing
@@ -41,9 +41,6 @@ CSRF_TRUSTED_ORIGINS = [
CELERY_TASK_ALWAYS_EAGER = env.bool("CELERY_TASK_ALWAYS_EAGER", default=False)
CELERY_TASK_EAGER_PROPAGATES = True
-# CORS for development (if needed for frontend)
-CORS_ALLOW_ALL_ORIGINS = True
-
# Django Debug Toolbar (optional)
if DEBUG:
INSTALLED_APPS += ["django_extensions"] # noqa
diff --git a/config/settings/prod.py b/config/settings/prod.py
index 05fed8a..6e88564 100644
--- a/config/settings/prod.py
+++ b/config/settings/prod.py
@@ -3,19 +3,21 @@ Production settings for PX360 project.
"""
from .base import * # noqa
-DEBUG = False
+DEBUG = env.bool("DEBUG", default=False)
+
+# Hosts and trusted origins (driven by environment, works for any deploy target)
+ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) + ["localhost", "127.0.0.1"]
+CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
# Caddy handles SSL termination, so trust the X-Forwarded-Proto header
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
-SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=False)
+SECURE_SSL_REDIRECT = env.bool('SECURE_SSL_REDIRECT', default=True)
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
-ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) + ['localhost', '127.0.0.1']
-
DATABASES = {
'default': env.db('DATABASE_URL')
}
diff --git a/deploy/.gitignore b/deploy/.gitignore
new file mode 100644
index 0000000..0ac9126
--- /dev/null
+++ b/deploy/.gitignore
@@ -0,0 +1,4 @@
+# Real env files contain secrets — never commit them.
+# The *.example files are committed templates.
+env.dev
+env.prod
diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml
new file mode 100644
index 0000000..126275c
--- /dev/null
+++ b/deploy/compose.dev.yml
@@ -0,0 +1,78 @@
+# PX360 — DEV compose for `target deploy` (bundled postgres + redis)
+#
+# target injects into the `web_app` service on deploy:
+# - container_name: -web_app (so the shared Caddy can route to it)
+# - networks: [target-proxy, default]
+# - removes any `ports:` (the shared Caddy handles ingress)
+#
+# The web service MUST be named `web_app` — target's Caddy injection looks for it.
+# `{{ .ImageTag }}` is replaced by target with the freshly built image tag.
+#
+# env file (in the deploy dir ~/.target/hh-dev/):
+# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.dev)
+services:
+ web_app:
+ image: {{ .ImageTag }}
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - media_volume:/app/media
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ ports:
+ - "8888:8000"
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
+
+ celery:
+ image: {{ .ImageTag }}
+ restart: unless-stopped
+ command: celery -A config worker -l info --concurrency=2
+ env_file:
+ - .env
+
+ # celery-beat:
+ # image: {{ .ImageTag }}
+ # restart: unless-stopped
+ # command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
+ # env_file:
+ # - .env
+
+ db:
+ image: postgres:15-alpine
+ restart: unless-stopped
+ ports:
+ - "5432:5432"
+ env_file:
+ - .env
+ volumes:
+ - pg_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-px360}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ pg_data:
+ redis_data:
+ media_volume:
diff --git a/deploy/compose.prod.yml b/deploy/compose.prod.yml
new file mode 100644
index 0000000..d68e3dc
--- /dev/null
+++ b/deploy/compose.prod.yml
@@ -0,0 +1,53 @@
+# PX360 — PROD compose for `target deploy` (external PostgreSQL, bundled redis)
+#
+# Same injection rules as compose.dev.yml: web service must be named `web_app`,
+# target adds container_name + networks and removes ports.
+#
+# The database is EXTERNAL (managed elsewhere). DATABASE_URL (in .env) is used by
+# Django; DB_HOST/DB_PORT/DB_USER (in .env) are used by the entrypoint's pg_isready.
+#
+# env file (in the deploy dir ~/.target/hh-prod/):
+# .env — ALL config + secrets, shipped by `target deploy` (deploy/env.prod)
+services:
+ web_app:
+ image: {{ .ImageTag }}
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - media_volume:/app/media
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/health/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 60s
+
+ celery:
+ image: {{ .ImageTag }}
+ restart: unless-stopped
+ command: celery -A config worker -l info --concurrency=4
+ env_file:
+ - .env
+
+ celery-beat:
+ image: {{ .ImageTag }}
+ restart: unless-stopped
+ command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
+ env_file:
+ - .env
+
+ redis:
+ image: redis:7-alpine
+ restart: unless-stopped
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+volumes:
+ redis_data:
+ media_volume:
diff --git a/deploy/env.dev.example b/deploy/env.dev.example
new file mode 100644
index 0000000..bb7d27e
--- /dev/null
+++ b/deploy/env.dev.example
@@ -0,0 +1,100 @@
+# ============================================================
+# PX360 DEV environment — ALL config + secrets in ONE file.
+# Shipped to the server as ~/.target/hh-dev/.env by `target deploy`
+# (see deploy/target.dev.yaml → env_file).
+#
+# This is the committed TEMPLATE. To create your real (gitignored) file:
+# make init-dev # copies this to deploy/env.dev
+# $EDITOR deploy/env.dev # fill in the secrets marked below
+# ============================================================
+
+# --- Django ---
+DJANGO_SETTINGS_MODULE=config.settings.prod
+DEBUG=True
+ALLOWED_HOSTS=django.ismailmosaibrahim.com,.ismailmosaibrahim.com
+CSRF_TRUSTED_ORIGINS=https://django.ismailmosaibrahim.com,
+ADMIN_URL=admin/
+SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
+
+# --- Database (dev: bundled postgres in compose.dev.yml) ---
+DB_HOST=db
+DB_PORT=5432
+DB_USER=px360
+POSTGRES_DB=px360
+POSTGRES_USER=px360
+# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
+DATABASE_URL=postgresql://px360:CHANGE-ME@db:5432/px360
+POSTGRES_PASSWORD=CHANGE-ME
+
+# --- Celery ---
+CELERY_BROKER_URL=redis://redis:6379/0
+CELERY_RESULT_BACKEND=redis://redis:6379/0
+CELERY_TASK_ALWAYS_EAGER=False
+
+# --- AI ---
+AI_MODEL=openrouter/deepseek/deepseek-v4-flash
+AI_TEMPERATURE=0.3
+AI_MAX_TOKENS=500
+#OPENROUTER_MODEL=google/gemma-3-27b-it:free
+ANALYSIS_ENABLED=True
+ANALYSIS_BATCH_SIZE=2
+OPENROUTER_API_KEY=
+
+# --- Notifications (dev: console providers) ---
+SMS_ENABLED=False
+SMS_PROVIDER=console
+WHATSAPP_ENABLED=False
+WHATSAPP_PROVIDER=console
+EMAIL_ENABLED=True
+EMAIL_PROVIDER=console
+
+# --- Email (dev: console) ---
+EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
+DEFAULT_FROM_EMAIL=noreply@px360.sa
+
+# --- HIS API ---
+HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
+HIS_API_USERNAME=
+HIS_API_PASSWORD=
+HIS_API_KEY=
+
+# --- Twilio ---
+TWILIO_ACCOUNT_SID=
+TWILIO_AUTH_TOKEN=
+TWILIO_PHONE_NUMBER=
+TWILIO_MESSAGING_SERVICE_SID=
+
+# --- Mshastra SMS ---
+MSHASTRA_USERNAME=
+MSHASTRA_PASSWORD=
+MSHASTRA_SENDER_ID=
+
+# --- External notification APIs ---
+EMAIL_API_KEY=
+SMS_API_KEY=
+
+# --- Social media ---
+YOUTUBE_API_KEY=
+YOUTUBE_CHANNEL_ID=
+FACEBOOK_PAGE_ID=
+FACEBOOK_ACCESS_TOKEN=
+INSTAGRAM_ACCOUNT_ID=
+INSTAGRAM_ACCESS_TOKEN=
+TWITTER_BEARER_TOKEN=
+TWITTER_USERNAME=
+LINKEDIN_ACCESS_TOKEN=
+LINKEDIN_ORGANIZATION_ID=
+
+# --- Google Reviews ---
+GOOGLE_CREDENTIALS_FILE=client_secret.json
+GOOGLE_TOKEN_FILE=token.json
+GOOGLE_LOCATIONS=
+
+# --- Other integration APIs ---
+MOH_API_URL=
+MOH_API_KEY=
+CHI_API_URL=
+CHI_API_KEY=
+
+# --- Dev only: redirect all SMS to this number when DEBUG=True ---
+DEV_SMS_RECIPIENT=
diff --git a/deploy/env.prod.example b/deploy/env.prod.example
new file mode 100644
index 0000000..cd9c1c8
--- /dev/null
+++ b/deploy/env.prod.example
@@ -0,0 +1,103 @@
+# ============================================================
+# PX360 PROD environment — ALL config + secrets in ONE file.
+# Shipped to the server as ~/.target/hh-prod/.env by `target deploy`
+# (see deploy/target.prod.yaml → env_file).
+#
+# This is the committed TEMPLATE. To create your real (gitignored) file:
+# make init-prod # copies this to deploy/env.prod
+# $EDITOR deploy/env.prod # fill in the secrets marked below
+# ============================================================
+
+# --- Django ---
+DJANGO_SETTINGS_MODULE=config.settings.prod
+DEBUG=False
+ALLOWED_HOSTS=your-production-domain.com
+CSRF_TRUSTED_ORIGINS=https://your-production-domain.com
+ADMIN_URL=CHANGE-ME/
+SECURE_SSL_REDIRECT=True
+SECRET_KEY=CHANGE-ME-run: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
+
+# --- Database (prod: EXTERNAL postgres) ---
+DB_HOST=your-db-host
+DB_PORT=5432
+DB_USER=px360
+POSTGRES_DB=px360
+POSTGRES_USER=px360
+# IMPORTANT: POSTGRES_PASSWORD must match the password embedded in DATABASE_URL.
+DATABASE_URL=postgresql://px360:CHANGE-ME@your-db-host:5432/px360
+POSTGRES_PASSWORD=CHANGE-ME
+
+# --- Celery ---
+CELERY_BROKER_URL=redis://redis:6379/0
+CELERY_RESULT_BACKEND=redis://redis:6379/0
+CELERY_TASK_ALWAYS_EAGER=False
+
+# --- AI ---
+AI_MODEL=z-ai/glm-4.5-air:free
+AI_TEMPERATURE=0.3
+AI_MAX_TOKENS=500
+OPENROUTER_MODEL=google/gemma-3-27b-it:free
+ANALYSIS_ENABLED=True
+ANALYSIS_BATCH_SIZE=10
+OPENROUTER_API_KEY=
+
+# --- Notifications ---
+SMS_ENABLED=False
+SMS_PROVIDER=console
+WHATSAPP_ENABLED=False
+WHATSAPP_PROVIDER=console
+EMAIL_ENABLED=True
+EMAIL_PROVIDER=console
+
+# --- Email (prod: SMTP) ---
+EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
+EMAIL_HOST=smtp.gmail.com
+EMAIL_PORT=587
+EMAIL_USE_TLS=True
+EMAIL_HOST_USER=
+EMAIL_HOST_PASSWORD=
+DEFAULT_FROM_EMAIL=noreply@px360.sa
+
+# --- HIS API ---
+HIS_API_URL=https://his.alhammadi.med.sa/SSRCE/API/FetchPatientVisitTimeStamps
+HIS_API_USERNAME=
+HIS_API_PASSWORD=
+HIS_API_KEY=
+
+# --- Twilio ---
+TWILIO_ACCOUNT_SID=
+TWILIO_AUTH_TOKEN=
+TWILIO_PHONE_NUMBER=
+TWILIO_MESSAGING_SERVICE_SID=
+
+# --- Mshastra SMS ---
+MSHASTRA_USERNAME=
+MSHASTRA_PASSWORD=
+MSHASTRA_SENDER_ID=
+
+# --- External notification APIs ---
+EMAIL_API_KEY=
+SMS_API_KEY=
+
+# --- Social media ---
+YOUTUBE_API_KEY=
+YOUTUBE_CHANNEL_ID=
+FACEBOOK_PAGE_ID=
+FACEBOOK_ACCESS_TOKEN=
+INSTAGRAM_ACCOUNT_ID=
+INSTAGRAM_ACCESS_TOKEN=
+TWITTER_BEARER_TOKEN=
+TWITTER_USERNAME=
+LINKEDIN_ACCESS_TOKEN=
+LINKEDIN_ORGANIZATION_ID=
+
+# --- Google Reviews ---
+GOOGLE_CREDENTIALS_FILE=client_secret.json
+GOOGLE_TOKEN_FILE=token.json
+GOOGLE_LOCATIONS=
+
+# --- Other integration APIs ---
+MOH_API_URL=
+MOH_API_KEY=
+CHI_API_URL=
+CHI_API_KEY=
diff --git a/deploy/target.dev.yaml b/deploy/target.dev.yaml
new file mode 100644
index 0000000..abf8757
--- /dev/null
+++ b/deploy/target.dev.yaml
@@ -0,0 +1,24 @@
+# PX360 — DEV deploy target.
+# Usage: `make deploy-dev` (runs: target deploy --config deploy/target.dev.yaml)
+app:
+ name: "hh-dev" # isolated project dir: ~/.target/hh-dev/
+ domain: "test.ismailmosaibrahim.com" # shared Caddy routes this domain → web_app
+ port: 8888
+
+server:
+ ip: "10.132.16.5"
+ user: "ahh-ops"
+ ssh_key: "~/.ssh/id_rsa"
+
+build:
+ engine: "local"
+ context: "." # repo root (where the Dockerfile lives)
+
+deploy:
+ use_caddy: false # shared target-caddy handles HTTPS
+ caddy_email: "ismail.mosa.ibrahim@gmail.com"
+ compose_file: "deploy/compose.dev.yml" # user-provided compose (uses {{ .ImageTag }})
+ env_file: "deploy/env.dev" # shipped to ~/.target/hh-dev/.env each deploy
+ pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
+ cleanup: ["remote", "local"]
+ keep_versions: 2
diff --git a/deploy/target.prod.yaml b/deploy/target.prod.yaml
new file mode 100644
index 0000000..e1ed8be
--- /dev/null
+++ b/deploy/target.prod.yaml
@@ -0,0 +1,26 @@
+# PX360 — PROD deploy target.
+# Usage: `make deploy-prod` (runs: target deploy --config deploy/target.prod.yaml)
+#
+# TODO: fill in the real prod server IP + domain below before first deploy.
+app:
+ name: "hh-prod" # isolated project dir: ~/.target/hh-prod/
+ domain: "your-production-domain.com" # TODO: real prod domain
+ port: 8000
+
+server:
+ ip: "10.10.1.132" # TODO: real prod server IP
+ user: "root"
+ ssh_key: "~/.ssh/id_rsa"
+
+build:
+ engine: "local"
+ context: "."
+
+deploy:
+ use_caddy: true
+ caddy_email: "ismail.mosa.ibrahim@gmail.com"
+ compose_file: "deploy/compose.prod.yml"
+ env_file: "deploy/env.prod" # shipped to ~/.target/hh-prod/.env each deploy
+ pre_deploy: "python manage.py migrate --noinput" # runs before the swap (old keeps serving)
+ cleanup: ["remote", "local"]
+ keep_versions: 3
diff --git a/scripts/db_sync.sh b/scripts/db_sync.sh
index 3abd92e..fb41135 100755
--- a/scripts/db_sync.sh
+++ b/scripts/db_sync.sh
@@ -50,6 +50,10 @@ EXCLUDE_TABLES=(
# Flags
DEPLOY=false
+CONTAINER="" # DB container name (e.g. hh-dev-db-1) → restore via docker exec
+MEDIA_CONTAINER="" # app container name (e.g. hh-dev-web_app) → media restore via docker exec
+PROJECT="" # compose project name (e.g. hh-dev) → for stop/start app containers
+NO_MEDIA=false # skip media dump+restore
# ============================================================
# Parse arguments
@@ -61,15 +65,39 @@ while [[ $# -gt 0 ]]; do
PROD_SSH="$2"
shift 2
;;
+ --container)
+ CONTAINER="$2"
+ shift 2
+ ;;
+ --media-container)
+ MEDIA_CONTAINER="$2"
+ shift 2
+ ;;
+ --project)
+ PROJECT="$2"
+ shift 2
+ ;;
+ --no-media)
+ NO_MEDIA=true
+ shift
+ ;;
--help|-h)
- echo "Usage: ./scripts/db_sync.sh [--deploy user@db-server-ip]"
+ echo "Usage: ./scripts/db_sync.sh [options] [--deploy user@host]"
echo ""
echo " Without --deploy: Creates a local dump only (dry-run)"
echo " With --deploy: Dumps locally, copies to prod DB server, restores"
echo ""
- echo "Example:"
- echo " ./scripts/db_sync.sh # local dump"
- echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # full deploy"
+ echo "Containerized target (DB runs in a Docker container on the server):"
+ echo " --container DB container (e.g. hh-dev-db-1)"
+ echo " --media-container app container for media (e.g. hh-dev-web_app)"
+ echo " --project compose project (e.g. hh-dev) for stop/start app"
+ echo " --no-media skip media dump+restore"
+ echo ""
+ echo "Examples:"
+ echo " ./scripts/db_sync.sh # local dump"
+ echo " ./scripts/db_sync.sh --deploy root@10.10.1.100 # host-installed pg tools"
+ echo " ./scripts/db_sync.sh --deploy root@host \\\\"
+ echo " --container hh-dev-db-1 --media-container hh-dev-web_app --project hh-dev"
exit 0
;;
*)
@@ -89,7 +117,9 @@ if [ "$DEPLOY" = true ]; then
fi
ENV_FILE="$PROJECT_DIR/.env.production"
- if [ -f "$ENV_FILE" ]; then
+ # In container mode the DB lives in the container; no DB_HOST needed.
+ # Only read .env.production for host-mode (no --container) or if it exists.
+ if [ -z "$CONTAINER" ] && [ -f "$ENV_FILE" ]; then
PROD_DB_HOST=$(grep -E "^DB_HOST=" "$ENV_FILE" | cut -d= -f2 || echo "")
PROD_DB_USER=$(grep -E "^DB_USER=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
PROD_DB_NAME=$(grep -E "^DB_NAME=" "$ENV_FILE" | cut -d= -f2 || echo "px360")
@@ -98,19 +128,30 @@ if [ "$DEPLOY" = true ]; then
fi
fi
- if [ -z "$PROD_DB_HOST" ]; then
+ if [ -z "$CONTAINER" ] && [ -z "$PROD_DB_HOST" ]; then
echo -e "${YELLOW}Warning: DB_HOST not found in .env.production${NC}"
echo -e "Enter DB server IP/hostname: "
read -r PROD_DB_HOST
fi
+ # Derive compose project from media container (target names it -web_app) if not given
+ if [ -n "$MEDIA_CONTAINER" ] && [ -z "$PROJECT" ]; then
+ PROJECT="${MEDIA_CONTAINER%-web_app}"
+ fi
+
echo -e "${BLUE}Production target:${NC}"
- echo -e " SSH: $PROD_SSH"
- echo -e " DB Host: $PROD_DB_HOST"
- echo -e " DB User: $PROD_DB_USER"
- echo -e " DB Name: $PROD_DB_NAME"
+ echo -e " SSH: $PROD_SSH"
+ if [ -n "$CONTAINER" ]; then
+ echo -e " DB container: $CONTAINER"
+ echo -e " Media target: ${MEDIA_CONTAINER:-}"
+ echo -e " Project: ${PROJECT:-}"
+ else
+ echo -e " DB Host: $PROD_DB_HOST"
+ fi
+ echo -e " DB User: $PROD_DB_USER"
+ echo -e " DB Name: $PROD_DB_NAME"
echo ""
- echo -e "${YELLOW}This will OVERWRITE production database data. Continue? (y/N)${NC}"
+ echo -e "${YELLOW}This will OVERWRITE the target database data. Continue? (y/N)${NC}"
read -r CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo "Aborted."
@@ -186,7 +227,10 @@ fi
echo ""
echo -e "${BLUE}[2/5] Dumping media files...${NC}"
-if [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
+if [ "$NO_MEDIA" = true ]; then
+ echo -e "${YELLOW} Skipped (--no-media).${NC}"
+ MEDIA_DUMP=""
+elif [ -d "$PROJECT_DIR/media" ] && [ "$(ls -A "$PROJECT_DIR/media" 2>/dev/null)" ]; then
tar -czf "$MEDIA_DUMP" -C "$PROJECT_DIR/media" .
MEDIA_SIZE=$(du -h "$MEDIA_DUMP" | cut -f1)
echo -e "${GREEN} Media dump: $MEDIA_DUMP ($MEDIA_SIZE)${NC}"
@@ -250,49 +294,113 @@ fi
echo ""
echo -e "${BLUE}[5/5] Restoring on production database...${NC}"
-ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
- set -e
- DB_USER="${PROD_DB_USER}"
- DB_NAME="${PROD_DB_NAME}"
- DB_HOST="${PROD_DB_HOST}"
+if [ -n "$CONTAINER" ]; then
+ # ----- Container mode: restore via docker exec into the DB container -----
- echo " Creating database if not exists..."
- createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
+ # Stop app containers so nothing holds connections during --clean restore
+ if [ -n "$PROJECT" ]; then
+ echo -e "${BLUE} Stopping app containers (project $PROJECT)...${NC}"
+ ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} stop web_app celery celery-beat 2>&1 || true"
+ fi
- echo " Restoring database (this may take a minute)..."
- pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
- --no-owner --no-privileges --clean --if-exists \
- --no-tablespaces \
- /tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
+ ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
+ set -e
+ DB_USER="${PROD_DB_USER}"
+ DB_NAME="${PROD_DB_NAME}"
+ DB_CONTAINER="${CONTAINER}"
- echo " Verifying restore..."
- psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
- SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
- " 2>/dev/null || true
+ echo " Copying dump into DB container \$DB_CONTAINER..."
+ docker cp /tmp/px360_restore.dump "\$DB_CONTAINER":/tmp/px360_restore.dump
- psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
- SELECT ' ' || relname || ': ' || n_live_tup
- FROM pg_stat_user_tables
- WHERE n_live_tup > 0
- ORDER BY n_live_tup DESC
- LIMIT 10;
- " 2>/dev/null || true
+ echo " Restoring database (this may take a minute)..."
+ docker exec "\$DB_CONTAINER" pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
+ --no-owner --no-privileges --clean --if-exists --no-tablespaces \
+ /tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
- echo " Cleaning up temp files..."
- rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
+ echo " Verifying restore..."
+ docker exec "\$DB_CONTAINER" psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
+ SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
+ " 2>/dev/null || true
+ docker exec "\$DB_CONTAINER" psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
+ SELECT ' ' || relname || ': ' || n_live_tup
+ FROM pg_stat_user_tables
+ WHERE n_live_tup > 0
+ ORDER BY n_live_tup DESC
+ LIMIT 10;
+ " 2>/dev/null || true
- echo " Restore complete."
+ echo " Cleaning DB container temp..."
+ docker exec "\$DB_CONTAINER" rm -f /tmp/px360_restore.dump
+ echo " DB restore complete."
REMOTE_SCRIPT
-# ============================================================
-# Media restore on app server (if separate from DB server)
-# ============================================================
-if [ -n "$MEDIA_DUMP" ]; then
- echo ""
- echo -e "${YELLOW}Note: Media files were uploaded to the DB server at /tmp/px360_media.tar.gz${NC}"
- echo -e "${YELLOW}If your app server is separate, copy and extract there:${NC}"
- echo -e " scp ${PROD_SSH}:/tmp/px360_media.tar.gz app-server:/tmp/"
- echo -e " ssh app-server 'mkdir -p /path/to/media && tar -xzf /tmp/px360_media.tar.gz -C /path/to/media'"
+ # Start app containers back up
+ if [ -n "$PROJECT" ]; then
+ echo -e "${BLUE} Starting app containers...${NC}"
+ ssh "$PROD_SSH" "cd \$HOME/.target/${PROJECT} && docker compose -p ${PROJECT} up -d 2>&1 || true"
+ fi
+
+ # Media restore (web_app must be running, so after `up -d`)
+ if [ -n "$MEDIA_DUMP" ] && [ -n "$MEDIA_CONTAINER" ]; then
+ echo ""
+ echo -e "${BLUE}Restoring media into ${MEDIA_CONTAINER}...${NC}"
+ ssh "$PROD_SSH" bash -s << REMOTE_MEDIA
+ set -e
+ MC="${MEDIA_CONTAINER}"
+ docker cp /tmp/px360_media.tar.gz "\$MC":/tmp/px360_media.tar.gz
+ # web_app runs as appuser (UID 1000); chown so the app can write uploads later
+ docker exec "\$MC" sh -c "mkdir -p /app/media && tar -xzf /tmp/px360_media.tar.gz -C /app/media && chown -R 1000:1000 /app/media"
+ docker exec "\$MC" rm -f /tmp/px360_media.tar.gz
+ echo " Media restored to \$MC:/app/media"
+REMOTE_MEDIA
+ fi
+
+ # Clean up host temp copies
+ ssh "$PROD_SSH" "rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz" 2>/dev/null || true
+
+else
+ # ----- Host mode: host-installed pg client tools (original behavior) -----
+ ssh "$PROD_SSH" bash -s << REMOTE_SCRIPT
+ set -e
+ DB_USER="${PROD_DB_USER}"
+ DB_NAME="${PROD_DB_NAME}"
+ DB_HOST="${PROD_DB_HOST}"
+
+ echo " Creating database if not exists..."
+ createdb -U "\$DB_USER" "\$DB_NAME" 2>/dev/null || echo " Database already exists."
+
+ echo " Restoring database (this may take a minute)..."
+ pg_restore -U "\$DB_USER" -d "\$DB_NAME" \
+ --no-owner --no-privileges --clean --if-exists \
+ --no-tablespaces \
+ /tmp/px360_restore.dump 2>&1 | grep -v "does not exist" || true
+
+ echo " Verifying restore..."
+ psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
+ SELECT ' Total tables: ' || count(*) FROM pg_stat_user_tables;
+ " 2>/dev/null || true
+
+ psql -U "\$DB_USER" -d "\$DB_NAME" -t -c "
+ SELECT ' ' || relname || ': ' || n_live_tup
+ FROM pg_stat_user_tables
+ WHERE n_live_tup > 0
+ ORDER BY n_live_tup DESC
+ LIMIT 10;
+ " 2>/dev/null || true
+
+ echo " Cleaning up temp files..."
+ rm -f /tmp/px360_restore.dump /tmp/px360_media.tar.gz
+
+ echo " Restore complete."
+REMOTE_SCRIPT
+
+ if [ -n "$MEDIA_DUMP" ]; then
+ echo ""
+ echo -e "${YELLOW}Note: Media files were uploaded to the DB server at /tmp/px360_media.tar.gz${NC}"
+ echo -e "${YELLOW}Restore manually on the app server:${NC}"
+ echo -e " scp ${PROD_SSH}:/tmp/px360_media.tar.gz app-server:/tmp/"
+ echo -e " ssh app-server 'mkdir -p /path/to/media && tar -xzf /tmp/px360_media.tar.gz -C /path/to/media'"
+ fi
fi
# ============================================================
diff --git a/static/images/stamps/Artboard 61@2x.png b/static/images/stamps/Artboard 61@2x.png
new file mode 100644
index 0000000..9bb9482
Binary files /dev/null and b/static/images/stamps/Artboard 61@2x.png differ
diff --git a/static/images/stamps/Artboard 62@2x.png b/static/images/stamps/Artboard 62@2x.png
new file mode 100644
index 0000000..4bd8cfd
Binary files /dev/null and b/static/images/stamps/Artboard 62@2x.png differ
diff --git a/static/images/stamps/stamp.png b/static/images/stamps/stamp.png
new file mode 100644
index 0000000..a81b1ca
Binary files /dev/null and b/static/images/stamps/stamp.png differ
diff --git a/templates/actions/action_create.html b/templates/actions/action_create.html
index aac4fa5..956fbeb 100644
--- a/templates/actions/action_create.html
+++ b/templates/actions/action_create.html
@@ -17,6 +17,25 @@
diff --git a/templates/actions/action_detail.html b/templates/actions/action_detail.html
index d3d70c1..75cba5d 100644
--- a/templates/actions/action_detail.html
+++ b/templates/actions/action_detail.html
@@ -6,6 +6,16 @@
{% block extra_css %}
+
+
+
+
+
+
تقرير الشكوى
+
+
+
+
+ | رقم الشكوى |
+ {{ complaint.reference_number }} |
+ الحالة |
+ {{ complaint.get_status_display }} |
+
+
+ | العنوان |
+ {{ complaint.title }} |
+ نوع الشكوى |
+ {{ complaint.get_complaint_type_display }} |
+
+
+ | القسم |
+ {% if complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %} |
+ الخطورة |
+ {{ complaint.get_severity_display }} |
+
+
+ | الموظف المشكو عليه |
+ {% if complaint.staff %}{{ complaint.staff.get_full_name }}{% else %}—{% endif %} |
+ جهة الادعاء |
+ {% if complaint.source %}{{ complaint.source.name_ar|default:complaint.source.name_en }}{% else %}—{% endif %} |
+
+
+ | تاريخ التقديم |
+ {{ complaint.created_at|date:"Y/m/d H:i" }} |
+ تاريخ الحادثة |
+ {% if complaint.incident_date %}{{ complaint.incident_date|date:"Y/m/d" }}{% else %}—{% endif %} |
+
+
+
+
+
+
+
+ | {{ complaint.description }} |
+
+
+
+
+ {% if complaint.patient %}
+
+
+
+ | اسم المريض |
+ {{ complaint.patient.get_full_name }} |
+ رقم الملف |
+ {{ complaint.patient.mrn|default:"—" }} |
+
+
+ {% endif %}
+
+
+ {% if complaint.contact_name or complaint.contact_phone %}
+
+
+
+ | الاسم |
+ {{ complaint.contact_name|default:"—" }} |
+ الهاتف |
+ {{ complaint.contact_phone|default:"—" }} |
+
+
+ {% endif %}
+
+
+ {% if complaint.involved_departments.exists %}
+
+ {% for dept in complaint.involved_departments.all %}
+
+
+ | القسم |
+ {{ dept.department.name_ar|default:dept.department.name_en }} |
+ تاريخ الرد |
+ {% if dept.response_submitted_at %}{{ dept.response_submitted_at|date:"Y/m/d H:i" }}{% else %}—{% endif %} |
+
+ {% if dept.response_notes %}
+
+ | {{ dept.response_notes }} |
+
+ {% endif %}
+
+ {% endfor %}
+ {% endif %}
+
+
+ {% if explanations %}
+
+ {% for exp in explanations %}
+ {% if exp.explanation %}
+
+
+ | الموظف |
+ {{ exp.staff.get_full_name|default:"—" }} |
+ تاريخ الرد |
+ {% if exp.responded_at %}{{ exp.responded_at|date:"Y/m/d H:i" }}{% else %}—{% endif %} |
+
+
+ | {{ exp.explanation }} |
+
+
+ {% endif %}
+ {% endfor %}
+ {% endif %}
+
+
+ {% if complaint.resolution %}
+
+
+
+ | {{ complaint.resolution }} |
+
+
+ {% endif %}
+
+
+
+
+ {% if stamp_path %}
+

+ {% endif %}
+
+
+