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 @@
{% csrf_token %} + {% if linked_complaint_id %} + + {% endif %} + + {% if form.errors %} +
+

{% trans "Please fix the following errors:" %}

+
    + {% for field in form %} + {% for error in field.errors %} +
  • {{ field.label }}: {{ error }}
  • + {% endfor %} + {% endfor %} + {% for error in form.non_field_errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %}
@@ -52,42 +71,48 @@
- - + +
- - + + + + + + +
- +
- - + + + +
- + + {% if linked_complaint %}
- - + +
+ +
+

{{ linked_complaint.reference_number }}

+

{{ linked_complaint.title|truncatechars:60 }}

+
+
+ {% endif %}
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 %} + + + + + + + + {% if dept.response_notes %} + + + + {% endif %} +
القسم{{ 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 %}
{{ dept.response_notes }}
+ {% 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 %} + + + diff --git a/templates/complaints/complaint_summary_pdf.html b/templates/complaints/complaint_summary_pdf.html index 8a12175..5daed0c 100644 --- a/templates/complaints/complaint_summary_pdf.html +++ b/templates/complaints/complaint_summary_pdf.html @@ -12,7 +12,6 @@ :root { --blue: #005696; - --blue-light: #007bbd; --ink: #1e293b; --body: #334155; --muted: #64748b; @@ -30,268 +29,208 @@ height: 297mm; position: relative; overflow: hidden; - page-break-after: always; + background-image: url('{{ letterhead_path }}'); + background-size: 210mm 297mm; + background-repeat: no-repeat; + background-position: top left; } - .page:last-child { page-break-after: auto; } - /* === Header === */ - .page-header { - position: relative; - height: 22mm; - margin: 8mm 20mm 0 20mm; - } - .header-ar { - position: absolute; - left: 0; - top: 4mm; - text-align: left; - } - .header-logo-wrap { - position: absolute; - left: 50%; - top: 0; - transform: translateX(-50%); - text-align: center; - } - .header-en { - position: absolute; - right: 0; - top: 4mm; - text-align: right; - } - .header-logo { - width: 55px; + .stamp { + position: fixed; + bottom: 22mm; + right: 20mm; + width: 40mm; height: auto; - object-fit: contain; - } - .header-hospital-name { - font-size: 14px; - font-weight: 700; - color: var(--blue); - } - .header-branch { - font-size: 10px; - color: var(--muted); - } - .header-cr { - font-size: 9px; - font-weight: 700; - color: var(--blue); - text-align: center; - } - .header-line { - height: 2px; - background: var(--blue); - margin: 8px 15mm 0 15mm; - } - - /* === Watermark === */ - .watermark { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - opacity: 0.04; - pointer-events: none; - z-index: 0; - } - .watermark img { - width: 300px; - height: auto; - } - - /* === Footer === */ - .page-footer { - position: absolute; - bottom: 0; - left: 0; - right: 0; - } - .footer-line { - height: 2px; - background: var(--blue); - margin: 0 15mm; - } - .footer-content { - padding: 6px 15mm 10mm 15mm; - text-align: center; - font-size: 8px; - color: var(--muted); - line-height: 1.8; - } - .footer-ar { - direction: rtl; - } - .footer-en { - direction: ltr; + z-index: 100; } /* === Form Body === */ .form-body { - padding: 8mm 15mm; + padding: 38mm 18mm 30mm 18mm; position: relative; z-index: 1; } + /* === Title === */ .form-title { text-align: center; - font-size: 16px; + font-size: 18px; + font-weight: 700; color: var(--blue); - padding: 10px 0 16px 0; + padding: 6px 0 14px 0; } + /* === Section Header === */ + .section-title { + background: var(--surface); + color: var(--blue); + font-size: 11px; + font-weight: 700; + padding: 6px 12px; + border: 1px solid var(--border); + border-bottom: none; + margin-top: 6px; + } + + /* === Data Tables === */ .data-table { width: 100%; border-collapse: collapse; - margin-top: 12px; - } - .data-table th { - background: var(--surface); - color: var(--blue); - font-size: 12px; - padding: 6px 12px; - border-right: 3px solid var(--blue); - border-left: 1px solid var(--border); - border-top: 1px solid var(--border); - text-align: right; } .data-table td { border: 1px solid var(--border); - font-size: 11px; - padding: 8px 12px; + font-size: 10px; + padding: 6px 10px; } .data-table .lbl { background: var(--surface); color: var(--body); font-weight: 700; - width: 15%; + width: 18%; } .data-table .val { color: var(--ink); - width: 35%; - } - .data-table .text-cell { - padding: 12px; - line-height: 2; - text-align: justify; + width: 32%; } - .signature-block { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--border); + /* === Summary Block === */ + .summary-box { + border: 1px solid var(--border); + padding: 12px; + font-size: 10px; + line-height: 2; + text-align: justify; + min-height: 40mm; + } + + /* === Responsible Department === */ + .responsible-dept { + margin-top: 8mm; + background: var(--blue); + color: var(--white); + text-align: center; + font-size: 12px; + font-weight: 700; + padding: 8px 12px; + border-radius: 4px; + } + + /* === Bottom: QR + Notice (fixed position, like stamp) === */ + .qr-block { + position: fixed; + bottom: 22mm; + left: 18mm; + z-index: 100; + } + .qr-block table { + border-collapse: collapse; + } + .qr-block td { + vertical-align: top; + padding: 0; + } + .qr-block img { + width: 25mm; + height: 25mm; + } + .qr-label { + font-size: 7px; + color: var(--muted); + margin-top: 2px; text-align: center; } - .signature-label { - font-size: 11px; - color: var(--blue); - } - .signature-line { - width: 200px; - border-bottom: 1px solid var(--border); - margin: 30px auto 0 auto; - height: 1px; - } - .signature-stamp-text { - font-size: 9px; + .qr-notice { + font-size: 8px; color: var(--muted); - margin-top: 8px; + line-height: 1.6; + text-align: right; + padding-right: 5mm; + max-width: 45mm; } -
- {% include "complaints/partials/pdf_letterhead_header.html" %} -
-
+
نموذج الشكوى
+ +
بيانات مقدم الشكوى
- - + + - - + + - - - + + + + + + +
رقم الملف{{ complaint.reference_number|default:"—" }} الاسم {{ complainant_name|default:"—" }}رقم الملف{{ complaint.reference_number|default:"—" }}
جهة الادعاء {{ source_name|default:"—" }}تاريخ التقديم{{ submission_date }}الموقع{{ department_name|default:"—" }}
تاريخ الحادثة{{ incident_date }}رقم الحالة{{ complaint.reference_number|default:"—" }}{{ incident_date|default:"—" }}تاريخ التقديم{{ submission_date|default:"—" }}
رقم الاتصال{{ complaint.contact_phone|default:"—" }}
+ + +
بيانات الموظف المشكو عليه
+ + + + - - - - + + - +
الموظف{{ accused_staff_name|default:"—" }} القسم {{ department_name|default:"—" }}الموظف المشكو عليه{{ accused_staff_name|default:"—" }}
الوظيفة{{ accused_staff_title|default:"—" }}القسم الرئيسي{% if complaint.department and complaint.department.parent %}{{ complaint.department.parent.name_ar|default:complaint.department.parent.name_en }}{% elif complaint.department %}{{ complaint.department.name_ar|default:complaint.department.name_en }}{% else %}—{% endif %} تاريخ الإرسال{{ sent_to_dept_date }}{{ sent_to_dept_date|default:"—" }}
- - - - - -
مختصر الشكوى
{{ content_summary }}
+ +
مختصر الشكوى
+
{{ content_summary|default:complaint.description }}
+ + +
قسم علاقات المرضى
- - {% include "complaints/partials/pdf_letterhead_footer.html" %}
- -
- {% include "complaints/partials/pdf_letterhead_header.html" %} -
- -
-
نموذج رد الشكوى
- - - - - - - - - - - - - - - - - - - - -
الموظف المشكو عليه{{ accused_staff_name|default:"—" }}الوظيفة{{ accused_staff_title|default:"—" }}
القسم{{ department_name|default:"—" }}تاريخ الرد{{ response_date }}
تاريخ الإرسال{{ sent_to_dept_date }}
- - - - - - -
مختصر الرد
{% if dept_response_summary %}{{ dept_response_summary }}{% else %}لم يتم تسجيل رد من القسم بعد.{% endif %}
-
- - {% include "complaints/partials/pdf_letterhead_footer.html" %} + + {% if qr_code_path %} +
+ + + + + +
+ QR +
للتحقق من صحة المستند
+
+ هذه الوثيقة صادرة إلكترونياً ولا تتطلب توقيعاً يدوياً.
+ This document is electronically generated
+ and does not require a signature. +
+ {% endif %} + + {% if stamp_path %} + + {% endif %} diff --git a/templates/complaints/explanation_form.html b/templates/complaints/explanation_form.html index 9d6c373..631372a 100644 --- a/templates/complaints/explanation_form.html +++ b/templates/complaints/explanation_form.html @@ -110,6 +110,64 @@ {% endif %}
+ +
+ + + @@ -124,6 +182,57 @@
{% csrf_token %} + {% if otp_sent %} + +
+
+
+ +

{% trans "Verify your submission" %}

+
+ +
+ +

+ {% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %} + {% elif otp_phone %}{% trans "Verification code sent to your phone." %} + {% elif otp_email %}{% trans "Verification code sent to your email." %} + {% else %}{% trans "Verification code sent." %}{% endif %} +

+ +
+ + +
+
+

{% trans "Enter the 6-digit code to complete your submission." %}

+ +
+
+ + +
+
+ + {% trans "Locked" %} +
+ {% endif %} +
{% if otp_sent %} - -
-

- {% if otp_phone and otp_email %}{% trans "Verification code sent to your phone and email." %} - {% elif otp_phone %}{% trans "Verification code sent to your phone." %} - {% elif otp_email %}{% trans "Verification code sent to your email." %} - {% else %}{% trans "Verification code sent." %}{% endif %} -

-
- - -
-

{% trans "Enter the 6-digit code to complete your submission." %}

+
- {% else %} - +
+ +{% include "core/workflow_stepper.html" %} +