diff --git a/apps/analytics/tasks.py b/apps/analytics/tasks.py index 0264f42..987c513 100644 --- a/apps/analytics/tasks.py +++ b/apps/analytics/tasks.py @@ -123,7 +123,7 @@ def precompute_dashboard_cache_task(self): from django.contrib.auth import get_user_model User = get_user_model() - admin_users = User.objects.filter(is_active=True, role="px_admin") + admin_users = User.objects.filter(is_active=True, groups__name="PX Admin") if not admin_users.exists(): # Fallback: use first superuser diff --git a/apps/appreciation/ui_views.py b/apps/appreciation/ui_views.py index e078996..211c434 100644 --- a/apps/appreciation/ui_views.py +++ b/apps/appreciation/ui_views.py @@ -97,7 +97,10 @@ def appreciation_detail(request, pk): ) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): if not (user.hospital and appreciation.hospital_id == user.hospital_id): messages.error(request, _("You don't have permission to view this appreciation.")) return redirect("appreciation:appreciation_list") @@ -145,7 +148,10 @@ def appreciation_activate(request, pk): return redirect("appreciation:appreciation_detail", pk=appreciation.pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): if not (user.hospital and appreciation.hospital_id == user.hospital_id): messages.error(request, _("Permission denied.")) return redirect("appreciation:appreciation_list") @@ -240,7 +246,10 @@ def appreciation_send(request, pk): return redirect("appreciation:appreciation_detail", pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): if not (user.hospital and appreciation.hospital_id == user.hospital_id): messages.error(request, _("Permission denied.")) return redirect("appreciation:appreciation_list") diff --git a/apps/complaints/migrations/0020_complaint_floor_complaint_zone.py b/apps/complaints/migrations/0020_complaint_floor_complaint_zone.py new file mode 100644 index 0000000..0c1b48e --- /dev/null +++ b/apps/complaints/migrations/0020_complaint_floor_complaint_zone.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-06-15 10:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaints', '0019_add_response_token'), + ] + + operations = [ + migrations.AddField( + model_name='complaint', + name='floor', + field=models.CharField(blank=True, default='', help_text='Floor where the incident occurred (defaults from department.floor)', max_length=50), + ), + migrations.AddField( + model_name='complaint', + name='zone', + field=models.CharField(blank=True, default='', help_text='Free-text zone/sub-area where the incident occurred', max_length=100), + ), + ] diff --git a/apps/complaints/migrations/0021_inquiry_satisfaction_inquiry_satisfaction_set_at.py b/apps/complaints/migrations/0021_inquiry_satisfaction_inquiry_satisfaction_set_at.py new file mode 100644 index 0000000..493f035 --- /dev/null +++ b/apps/complaints/migrations/0021_inquiry_satisfaction_inquiry_satisfaction_set_at.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-06-16 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('complaints', '0020_complaint_floor_complaint_zone'), + ] + + operations = [ + migrations.AddField( + model_name='inquiry', + name='satisfaction', + field=models.CharField(blank=True, choices=[('satisfied', 'Satisfied'), ('neutral', 'Neutral'), ('dissatisfied', 'Dissatisfied'), ('no_response', 'No Response')], default='', max_length=20), + ), + migrations.AddField( + model_name='inquiry', + name='satisfaction_set_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/complaints/models.py b/apps/complaints/models.py index 2f2e8ea..23419d8 100644 --- a/apps/complaints/models.py +++ b/apps/complaints/models.py @@ -281,6 +281,18 @@ class Complaint(UUIDModel, TimeStampedModel, SoftDeleteModel): area = models.ForeignKey( "organizations.Area", on_delete=models.SET_NULL, null=True, blank=True, related_name="complaints" ) + zone = models.CharField( + max_length=100, + blank=True, + default="", + help_text="Free-text zone/sub-area where the incident occurred", + ) + floor = models.CharField( + max_length=50, + blank=True, + default="", + help_text="Floor where the incident occurred (defaults from department.floor)", + ) # Complaint details title = models.CharField(max_length=500) @@ -1798,6 +1810,13 @@ 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) diff --git a/apps/complaints/services/complaint_service.py b/apps/complaints/services/complaint_service.py index fe30f7d..f30934e 100644 --- a/apps/complaints/services/complaint_service.py +++ b/apps/complaints/services/complaint_service.py @@ -83,7 +83,7 @@ class ComplaintService: return True if complaint.assigned_to and complaint.assigned_to == user: return True - if complaint.involved_departments.filter(id=user.department_id).exists() if user.department_id else False: + if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists(): return True return False @@ -372,7 +372,8 @@ class ComplaintService: resolution_outcome_other="", resolution_category="", ): - if not (changed_by.is_px_admin() or changed_by.is_hospital_admin()): + if not (changed_by.is_px_admin() or changed_by.is_hospital_admin() + or changed_by.is_px_management() or changed_by.is_px_employee()): raise ComplaintServiceError("You don't have permission to change complaint status.") if not new_status: @@ -567,6 +568,139 @@ class ComplaintService: "old_department": old_department, } + @staticmethod + def update_location(complaint, *, location_type, area, department, section, changed_by, request=None, zone=None, floor=None): + """Update the location-related fields of a complaint. + + Args: + complaint: Complaint instance + location_type: str (one of LocationType values, or "" to clear) + area: Area instance or None + department: Department instance or None + section: Section instance or None + changed_by: User performing the change + request: HttpRequest (for audit logging) + zone: str or None (free-text zone; None = leave unchanged, "" = clear) + floor: str or None (floor; None = leave unchanged, "" = clear or default from dept) + + Any of the FK args may be None to clear the field. ``location_type`` may + be "" to clear. Only fields whose value actually changes are written. + + If ``floor`` is empty AND a department is provided, the department's + ``floor`` value is used as the default. + """ + if not complaint.is_active_status: + raise ComplaintServiceError( + f"Cannot update location for complaint with status '{complaint.get_status_display()}'. " + "Complaint must be Open, In Progress, or Partially Resolved." + ) + + if not (changed_by.is_px_admin() or changed_by.is_hospital_admin() + or changed_by.is_px_management() or changed_by.is_px_employee()): + raise ComplaintServiceError("You don't have permission to update complaint location.") + + if area is not None and area.hospital_id != complaint.hospital_id: + raise ComplaintServiceError("Area does not belong to this complaint's hospital.") + + if department is not None and department.hospital_id != complaint.hospital_id: + raise ComplaintServiceError("Department does not belong to this complaint's hospital.") + + if section is not None and department is not None and section.department_id != department.id: + raise ComplaintServiceError("Section does not belong to the selected department.") + + # Floor default-from-department: an empty floor falls back to the department's floor. + if floor is not None and not floor.strip() and department is not None and department.floor: + floor = department.floor + + update_fields = [] + changes = [] + + old_location_type = complaint.location_type + if location_type != old_location_type: + complaint.location_type = location_type + update_fields.append("location_type") + changes.append(("location_type", old_location_type, location_type)) + + old_area = complaint.area + if area != old_area: + complaint.area = area + update_fields.append("area") + changes.append(("area", str(old_area.id) if old_area else None, + str(area.id) if area else None)) + + old_department = complaint.department + if department != old_department: + complaint.department = department + update_fields.append("department") + changes.append(("department", str(old_department.id) if old_department else None, + str(department.id) if department else None)) + + # If department changed and the current section no longer matches, clear it. + if complaint.section is not None and department is not None and complaint.section.department_id != department.id: + old_section = complaint.section + complaint.section = None + update_fields.append("section") + changes.append(("section", str(old_section.id), None)) + elif section is not None and section != complaint.section: + old_section = complaint.section + complaint.section = section + update_fields.append("section") + changes.append(("section", str(old_section.id) if old_section else None, + str(section.id) if section else None)) + elif section is None and complaint.section is not None: + old_section = complaint.section + complaint.section = None + update_fields.append("section") + changes.append(("section", str(old_section.id), None)) + + if zone is not None and zone != complaint.zone: + old_zone = complaint.zone + complaint.zone = zone + update_fields.append("zone") + changes.append(("zone", old_zone, zone)) + + if floor is not None and floor != complaint.floor: + old_floor = complaint.floor + complaint.floor = floor + update_fields.append("floor") + changes.append(("floor", old_floor, floor)) + + if not update_fields: + return {"success": True, "complaint": complaint, "changes": []} + + complaint.save(update_fields=update_fields) + + change_summary = ", ".join( + f"{field}: {('cleared' if not new else new)}" for field, old, new in changes + ) + ComplaintUpdate.objects.create( + complaint=complaint, + update_type="assignment", + message=f"Location details updated ({change_summary}).", + created_by=changed_by, + metadata={"changes": {field: {"old": old, "new": new} for field, old, new in changes}}, + ) + + metadata = {"changes": {field: {"old": old, "new": new} for field, old, new in changes}} + if request: + AuditService.log_from_request( + event_type="location_update", + description=f"Complaint location details updated ({change_summary}).", + request=request, + content_object=complaint, + metadata=metadata, + ) + else: + AuditService.log_event( + event_type="location_update", + description=f"Complaint location details updated ({change_summary}).", + user=changed_by, + content_object=complaint, + metadata=metadata, + ) + + return {"success": True, "complaint": complaint, "changes": changes} + @staticmethod def send_to_department( complaint, @@ -839,6 +973,34 @@ This is an automated message from PX360 Complaint Management System.""" "manager_count": 0, } + @staticmethod + def ensure_involved_records(complaint): + """Ensure the complaint's primary department and staff exist as involved records. + + Called lazily from the complaint detail view. Uses get_or_create so it's + idempotent — only creates records that don't exist yet. + """ + from apps.complaints.models import ComplaintInvolvedDepartment, ComplaintInvolvedStaff + + if complaint.department_id: + ComplaintInvolvedDepartment.objects.get_or_create( + complaint=complaint, + department=complaint.department, + defaults={ + "role": "primary", + "is_primary": True, + }, + ) + + if complaint.staff_id: + ComplaintInvolvedStaff.objects.get_or_create( + complaint=complaint, + staff=complaint.staff, + defaults={ + "role": "accused", + }, + ) + @staticmethod def post_create_hooks(complaint, created_by, request=None): from apps.complaints.tasks import analyze_complaint_with_ai, notify_admins_new_complaint diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index 12c2bf3..b04ec7f 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -605,6 +605,8 @@ def complaint_detail(request, pk): complaint = get_object_or_404(complaint_queryset, pk=pk) + ComplaintService.ensure_involved_records(complaint) + user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and complaint.hospital != user.hospital: @@ -680,11 +682,27 @@ def complaint_detail(request, pk): "attachments": attachments, "px_actions": px_actions, "assignable_users": assignable_users, + "send_to_users": User.objects.filter( + is_active=True, hospital=complaint.hospital + ).select_related("department").order_by("first_name", "last_name"), "status_choices": ComplaintStatus.choices, "base_layout": base_layout, "source_user": source_user, "can_edit": can_manage_complaint(user, complaint), - "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), + "can_manage_actions": ( + user.is_px_admin() + or (user.is_hospital_admin() and user.hospital == complaint.hospital) + or (user.is_px_management() and user.hospital == complaint.hospital) + or (user.is_px_employee() and user.hospital == complaint.hospital) + or (complaint.assigned_to == user) + ), + "can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital), + "can_review_dept_response": ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ), "is_active_status": complaint.is_active_status, "ai_department_suggested": ( bool(complaint.department) @@ -1706,7 +1724,10 @@ def complaint_escalate(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to escalate complaints.") return redirect("complaints:complaint_detail", pk=pk) @@ -2045,7 +2066,10 @@ def complaint_export_monthly_calculations(request): from apps.complaints.utils import export_monthly_calculations from django.core.exceptions import PermissionDenied - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") @@ -2087,7 +2111,10 @@ def complaint_export_quarterly_calculations(request): from apps.complaints.utils import export_quarterly_calculations from django.core.exceptions import PermissionDenied - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") @@ -2124,7 +2151,10 @@ def complaint_export_yearly_calculations(request): from apps.complaints.utils import export_yearly_calculations from django.core.exceptions import PermissionDenied - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): raise PermissionDenied("Only PX Admins and Hospital Admins can export.") year = request.GET.get("year") @@ -2471,12 +2501,22 @@ def inquiry_detail(request, pk): "stage_timeline": stage_timeline, "attachments": attachments, "assignable_users": assignable_users, + "send_to_users": User.objects.filter( + is_active=True, hospital=inquiry.hospital + ).select_related("department").order_by("first_name", "last_name"), "hospital_departments": hospital_departments, "status_choices": status_choices, - "can_edit": user.is_px_admin() or user.is_hospital_admin(), + "can_edit": ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ), "can_respond": ( user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() or inquiry.assigned_to == user or ( user.is_champion() @@ -2484,8 +2524,19 @@ def inquiry_detail(request, pk): and user.department in [inquiry.department, inquiry.outgoing_department] ) ), - "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), - "can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), + "can_review_dept_response": ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ), + "can_send_reminder": ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ), + "can_admin": user.is_px_admin() or user.is_hospital_admin(), "base_layout": base_layout, "source_user": source_user, "linked_rcas": linked_rcas, @@ -2526,7 +2577,10 @@ def inquiry_send_to_staff(request, pk): inquiry = get_object_or_404(Inquiry, pk=pk) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): messages.error(request, _("You don't have permission to perform this action.")) return redirect("inquiries:inquiry_detail", pk=pk) @@ -2715,7 +2769,10 @@ def inquiry_edit(request, pk): inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, _("You don't have permission to edit this inquiry.")) return redirect("inquiries:inquiry_detail", pk=inquiry.pk) @@ -2790,7 +2847,10 @@ def inquiry_activate(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to activate inquiries.") return redirect("inquiries:inquiry_detail", pk=pk) @@ -2938,7 +2998,10 @@ def inquiry_change_status(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to change inquiry status.") return redirect("inquiries:inquiry_detail", pk=pk) @@ -3076,17 +3139,15 @@ def inquiry_respond(request, pk): messages.error(request, "You don't have permission to respond to inquiries.") return redirect("inquiries:inquiry_detail", pk=pk) - response_en = request.POST.get("response_en", "").strip() - response_ar = request.POST.get("response_ar", "").strip() - response = response_en or response_ar + response = request.POST.get("response", "").strip() if not response: - messages.error(request, "Please enter a response in at least one language.") + messages.error(request, "Please enter a response.") return redirect("inquiries:inquiry_detail", pk=pk) inquiry.response = response - inquiry.response_en = response_en - inquiry.response_ar = response_ar + inquiry.response_en = "" + inquiry.response_ar = "" inquiry.responded_at = timezone.now() inquiry.responded_by = request.user inquiry.status = "resolved" @@ -3173,6 +3234,20 @@ 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): @@ -3185,7 +3260,7 @@ def inquiry_transfer_to_department(request, pk): user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to transfer inquiries to departments.")) return redirect("inquiries:inquiry_detail", pk=pk) @@ -3336,7 +3411,7 @@ def inquiry_escalate(request, pk): if not ( user.is_px_admin() or user.is_hospital_admin() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to escalate inquiries.")) return redirect("inquiries:inquiry_detail", pk=pk) @@ -3430,7 +3505,7 @@ def inquiry_send_to(request, pk): user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): return JsonResponse({ "success": False, @@ -3711,7 +3786,10 @@ def inquiry_review_dept_response(request, pk): inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to review department responses.") return redirect("inquiries:inquiry_detail", pk=pk) @@ -3840,7 +3918,10 @@ def inquiry_send_dept_response_reminder(request, pk): inquiry = get_object_or_404(Inquiry, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to send reminders.") return redirect("inquiries:inquiry_detail", pk=pk) @@ -4934,7 +5015,10 @@ def escalation_rule_list(request): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to manage escalation rules.") return redirect("accounts:settings") @@ -4988,7 +5072,10 @@ def escalation_rule_create(request): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to create escalation rules.") return redirect("accounts:settings") @@ -5038,7 +5125,10 @@ def escalation_rule_edit(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to edit escalation rules.") return redirect("accounts:settings") @@ -5095,7 +5185,10 @@ def escalation_rule_delete(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to delete escalation rules.") return redirect("accounts:settings") @@ -5134,7 +5227,10 @@ def complaint_threshold_list(request): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to manage complaint thresholds.") return redirect("accounts:settings") @@ -5188,7 +5284,10 @@ def complaint_threshold_create(request): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to create complaint thresholds.") return redirect("accounts:settings") @@ -5238,7 +5337,10 @@ def complaint_threshold_edit(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to edit complaint thresholds.") return redirect("accounts:settings") @@ -5295,7 +5397,10 @@ def complaint_threshold_delete(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to delete complaint thresholds.") return redirect("accounts:settings") @@ -5725,7 +5830,10 @@ def involved_department_review_response(request, pk): complaint = involved_dept.complaint user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, _("You don't have permission to review department responses.")) return redirect("complaints:complaint_detail", pk=complaint.pk) @@ -6548,7 +6656,7 @@ def government_ticket_list(request): # Permission check: PX Admin or PX Employee only if not (request.user.is_px_admin() or request.user.is_px_management()): messages.error(request, _("You don't have permission to view government tickets.")) - return redirect("dashboard:index") + return redirect("dashboard:command-center") # Base queryset queryset = GovernmentTicket.objects.select_related("source", "department", "assigned_to").all() @@ -6984,7 +7092,10 @@ def government_ticket_export(request): @require_http_methods(["POST"]) def complaint_soft_delete(request, pk): complaint = get_object_or_404(Complaint, pk=pk) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to delete complaints.")) complaint.soft_delete(user=request.user) messages.success(request, _("Complaint moved to trash.")) @@ -6995,7 +7106,10 @@ def complaint_soft_delete(request, pk): @require_http_methods(["POST"]) def complaint_restore(request, pk): complaint = get_object_or_404(Complaint.all_objects, pk=pk, is_deleted=True) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to restore complaints.")) complaint.restore() messages.success(request, _("Complaint restored successfully.")) @@ -7006,7 +7120,10 @@ def complaint_restore(request, pk): @require_http_methods(["POST"]) def inquiry_restore(request, pk): inquiry = get_object_or_404(Inquiry.all_objects, pk=pk, is_deleted=True) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to restore inquiries.")) inquiry.restore() messages.success(request, _("Inquiry restored successfully.")) @@ -7015,7 +7132,10 @@ def inquiry_restore(request, pk): @login_required def trash_list(request): - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to view trash.")) deleted_complaints = Complaint.all_objects.filter(is_deleted=True).select_related( diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index 53118d0..62a9e0d 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -52,6 +52,7 @@ urlpatterns = [ name="update_explanation_delay_reason", ), path("/change-department/", ui_views.complaint_change_department, name="complaint_change_department"), + path("/update-location/", ui_views.complaint_update_location, name="complaint_update_location"), 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"), diff --git a/apps/complaints/urls_inquiries.py b/apps/complaints/urls_inquiries.py index efc9da4..8ce6dfc 100644 --- a/apps/complaints/urls_inquiries.py +++ b/apps/complaints/urls_inquiries.py @@ -17,6 +17,7 @@ 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 5850fd7..b5469cb 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -1328,9 +1328,10 @@ This is an automated message from PX360 Complaint Management System. complaint = self.get_object() # Check permission - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not (request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee()): return Response( - {"error": "Only PX Admins or Hospital Admins can review explanations"}, status=status.HTTP_403_FORBIDDEN + {"error": "Only PX team members can review explanations"}, status=status.HTTP_403_FORBIDDEN ) explanation_id = request.data.get("explanation_id") @@ -4024,7 +4025,9 @@ def champion_start_investigation(request, complaint_id, token): respond_url = f"https://{domain}/complaints/{complaint.id}/investigate/respond/{resp_token}/" - staff_email = staff_member.email or (staff_member.user.email if hasattr(staff_member, 'user') and staff_member.user else None) + staff_user = staff_member.user if hasattr(staff_member, 'user') and staff_member.user else None + staff_email = staff_member.email or (staff_user.email if staff_user else None) + if staff_email: try: NotificationService.send_email( @@ -4054,6 +4057,7 @@ def champion_start_investigation(request, complaint_id, token): """, related_object=complaint, + user=staff_user, ) inv_response.email_sent_at = timezone.now() inv_response.save(update_fields=["email_sent_at"]) @@ -4061,6 +4065,16 @@ def champion_start_investigation(request, complaint_id, token): import logging logging.getLogger(__name__).error(f"Failed to send investigation email to {staff_email}: {e}") + elif staff_user: + from apps.notifications.models import UserNotification + UserNotification.objects.create( + user=staff_user, + title=f"Investigation Questions - Complaint #{complaint.reference_number}", + message=f"You have investigation questions to answer for complaint #{complaint.reference_number}.", + notification_type="system", + content_object=complaint, + ) + staff_phone = staff_member.phone or (staff_member.user.phone if hasattr(staff_member, 'user') and staff_member.user else None) if staff_phone: try: @@ -4146,12 +4160,15 @@ def staff_investigation_form(request, complaint_id, token): investigation.save(update_fields=["status"]) champion = investigation.champion - if champion and champion.email: - domain = request.get_host() - review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/" + champion_user = champion.user if champion and hasattr(champion, 'user') and champion.user else None + champion_email = champion.email if champion else None + domain = request.get_host() + review_url = f"https://{domain}/complaints/{complaint.id}/investigate/review/{investigation.explanation.token}/" + + if champion_email: try: NotificationService.send_email( - email=champion.email, + email=champion_email, subject=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", message=( f"Dear {champion.get_full_name()},\n\n" @@ -4174,9 +4191,21 @@ def staff_investigation_form(request, complaint_id, token): """, related_object=complaint, + user=champion_user, ) except Exception: - pass + import logging + logging.getLogger(__name__).error(f"Failed to send investigation review email to {champion_email}") + + elif champion_user: + from apps.notifications.models import UserNotification + UserNotification.objects.create( + user=champion_user, + title=f"All Investigation Responses Received - Complaint #{complaint.reference_number}", + message=f"All accused staff have responded. Please review and submit your final reply.", + notification_type="system", + content_object=complaint, + ) ComplaintUpdate.objects.create( complaint=complaint, diff --git a/apps/core/management/commands/get_e2e_project_state.py b/apps/core/management/commands/get_e2e_project_state.py new file mode 100644 index 0000000..8fee67f --- /dev/null +++ b/apps/core/management/commands/get_e2e_project_state.py @@ -0,0 +1,34 @@ +""" +Test-only helper: print QI project + task state for E2E assertions. + +Usage: + manage.py get_e2e_project_state +""" + +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = "Print QI project + task state (E2E helper)." + + def add_arguments(self, parser): + parser.add_argument("project_id", help="QIProject UUID") + + def handle(self, *args, **options): + from apps.projects.models import QIProject + + try: + p = QIProject.objects.get(id=options["project_id"]) + except Exception as exc: + raise CommandError(f"Project {options['project_id']} not found: {exc}") + + print(f"project_status={p.status}") + print(f"team_count={p.team_members.count()}") + print(f"task_count={p.tasks.count()}") + for t in p.tasks.all().order_by("created_at"): + sid = str(t.id)[:8] + assignee = t.assigned_to.get_full_name() if t.assigned_to else "NONE" + print(f"task_{sid}_title={t.title}") + print(f"task_{sid}_status={t.status}") + print(f"task_{sid}_assignee={assignee}") + print(f"task_{sid}_completed={'True' if t.completed_date else 'False'}") diff --git a/apps/core/management/commands/seed_e2e_project.py b/apps/core/management/commands/seed_e2e_project.py new file mode 100644 index 0000000..c7e370e --- /dev/null +++ b/apps/core/management/commands/seed_e2e_project.py @@ -0,0 +1,86 @@ +""" +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. + +Usage: + manage.py seed_e2e_project + -> prints: project_id= task_a_id= task_b_id= staff_a= staff_b= +""" + +from django.core.management.base import BaseCommand + +from apps.organizations.models import Department, Hospital, Staff + + +class Command(BaseCommand): + help = "Seed a QI project with multi-dept team + tasks for E2E testing." + + def handle(self, *args, **options): + from apps.projects.models import ( + QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, + FOCUSPhase, FOCUSPhaseChoices, + ) + from apps.accounts.models import User + + e2e = Hospital.objects.get(code="E2E-HOSP") + depts = list(Department.objects.filter(hospital=e2e)) + dept_a = depts[0] # Contact Center (champion'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) + staff_user_a = User.objects.filter(email="e2e-staff@px360.test").first() + staff_user_b = User.objects.filter(email="e2e-nurse@px360.test").first() + + staff_a, _ = Staff.objects.get_or_create( + user=staff_user_a, defaults={ + "first_name": "E2E", "last_name": "Staff", "hospital": e2e, + "department": dept_a, "status": "active", "staff_type": "other", + "job_title": "Staff", "employee_id": "E2E-QI-STAFF", + }) + staff_b, _ = Staff.objects.get_or_create( + user=staff_user_b, defaults={ + "first_name": "E2E", "last_name": "Nurse", "hospital": e2e, + "department": dept_b, "status": "active", "staff_type": "nurse", + "job_title": "Nurse", "employee_id": "E2E-QI-NURSE", + }) + # Fix department if needed + if staff_a.department_id != dept_a.id: + staff_a.department = dept_a; staff_a.save(update_fields=["department"]) + if staff_b.department_id != dept_b.id: + staff_b.department = dept_b; staff_b.save(update_fields=["department"]) + + 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) + + # Create PDCA phases + plan_phase = None + for phase_val in PDCAPhaseChoices.values: + p = PDCAPhase.objects.create(project=project, phase=phase_val) + if phase_val == "plan": + plan_phase = p + + # Create FOCUS phases + for phase_val in FOCUSPhaseChoices.values: + FOCUSPhase.objects.create(project=project, phase=phase_val) + + # Create tasks in the Plan phase, assigned to each team member + task_a = QIProjectTask.objects.create( + project=project, title=f"E2E Task A (staff dept)", description="Task for staff member", + pdca_phase=plan_phase, assigned_to=staff_a, status="pending", + ) + task_b = QIProjectTask.objects.create( + project=project, title=f"E2E Task B (nurse dept)", description="Task for nurse from different dept", + pdca_phase=plan_phase, assigned_to=staff_b, status="pending", + ) + + print(f"project_id={project.id} task_a_id={task_a.id} task_b_id={task_b.id} " + f"staff_a={staff_user_a.email} staff_b={staff_user_b.email} " + f"dept_a={dept_a.id} dept_b={dept_b.id}") diff --git a/apps/core/views.py b/apps/core/views.py index 976886a..ba3fbb0 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -441,10 +441,22 @@ def _track_complaint(reference): except Complaint.DoesNotExist: return JsonResponse({"found": False, "error": "Complaint not found"}) + from datetime import timedelta + from django.utils import timezone + + expiry_base = complaint.resolved_at or complaint.closed_at + if expiry_base and timezone.now() > expiry_base + timedelta(days=5): + return JsonResponse({ + "found": True, + "expired": True, + "reference": complaint.reference_number, + "type": "complaint", + }) + ps = complaint.public_status public_updates = list( - complaint.updates.filter(update_type__in=["status_change", "resolution"]) + complaint.updates.filter(update_type="resolution") .order_by("-created_at")[:20] ) @@ -457,19 +469,32 @@ def _track_complaint(reference): timeline = [] for u in public_updates: - icon = "refresh-cw" if u.update_type == "status_change" else "check-circle-2" - title = "Status Updated" if u.update_type == "status_change" else "Final Resolution" msg = u.message or "" for internal, public_label in _status_map.items(): msg = msg.replace(internal, public_label) timeline.append({ "type": u.update_type, - "icon": icon, - "title": title, + "icon": "check-circle-2", + "title": "Final Resolution", "comment": msg, "created_at": u.created_at.strftime("%Y-%m-%d %H:%M"), }) + dept_responses = complaint.involved_departments.filter( + response_submitted=True, + ).select_related("department").order_by("-response_submitted_at") + for dr in dept_responses: + timeline.append({ + "type": "response", + "icon": "message-square", + "title": "Department Response", + "department": dr.department.name if dr.department else "", + "comment": dr.response_notes or "", + "created_at": dr.response_submitted_at.strftime("%Y-%m-%d %H:%M") if dr.response_submitted_at else "", + }) + + timeline.sort(key=lambda x: x["created_at"], reverse=True) + info_cards = [ {"icon": "calendar", "label": "Submitted", "value": complaint.created_at.strftime("%b %d, %Y")}, {"icon": "building", "label": "Department", "value": complaint.department.name if complaint.department else "General"}, @@ -521,14 +546,6 @@ def _track_inquiry(reference): sm = status_map.get(inquiry.status, {"label": inquiry.get_status_display(), "progress": 15, "css": "amber"}) timeline = [] - if inquiry.status in ("resolved", "closed") and (inquiry.department_response_en or inquiry.department_response_ar): - timeline.append({ - "type": "response", - "icon": "check-circle-2", - "title": "Response Sent", - "comment": "", - "created_at": (inquiry.department_responded_at or inquiry.updated_at).strftime("%Y-%m-%d %H:%M"), - }) info_cards = [ {"icon": "calendar", "label": "Submitted", "value": inquiry.created_at.strftime("%b %d, %Y")}, @@ -556,10 +573,11 @@ def _track_inquiry(reference): "info_cards": info_cards, "timeline": timeline, "response": { - "has_response": bool(inquiry.department_response_en or inquiry.department_response_ar), - "en": inquiry.department_response_en or "", - "ar": inquiry.department_response_ar or "", + "has_response": bool(inquiry.response_en or inquiry.response_ar or inquiry.response), + "en": inquiry.response_en or inquiry.response or "", + "ar": inquiry.response_ar or "", }, + "satisfaction": inquiry.satisfaction or "", }) @@ -581,14 +599,6 @@ def _track_observation(reference): } timeline = [] - if observation.status in ("resolved", "closed") and (observation.department_response_en or observation.department_response_ar): - timeline.append({ - "type": "response", - "icon": "check-circle-2", - "title": "Response Sent", - "comment": "", - "created_at": (observation.department_responded_at or observation.updated_at).strftime("%Y-%m-%d %H:%M"), - }) info_cards = [ {"icon": "calendar", "label": "Submitted", "value": observation.created_at.strftime("%b %d, %Y")}, @@ -608,10 +618,11 @@ def _track_observation(reference): "info_cards": info_cards, "timeline": timeline, "response": { - "has_response": bool(observation.department_response_en or observation.department_response_ar), - "en": observation.department_response_en or "", - "ar": observation.department_response_ar or "", + "has_response": bool(observation.response_en or observation.response_ar or observation.response), + "en": observation.response_en or observation.response or "", + "ar": observation.response_ar or "", }, + "satisfaction": observation.satisfaction or "", }) @@ -746,9 +757,8 @@ def add_note(request): @require_POST @csrf_exempt def public_set_satisfaction(request): - """Public endpoint to set patient satisfaction for a complaint (no auth required).""" + """Public endpoint to set patient satisfaction (no auth required).""" from django.utils import timezone - from apps.complaints.models import Complaint reference = request.POST.get("reference", "").strip() satisfaction = request.POST.get("satisfaction", "").strip() @@ -760,16 +770,28 @@ def public_set_satisfaction(request): if satisfaction not in valid_choices: return JsonResponse({"success": False, "error": "Invalid satisfaction value."}, status=400) + upper = reference.upper() + try: - complaint = Complaint.objects.get(reference_number__iexact=reference) - except Complaint.DoesNotExist: - return JsonResponse({"success": False, "error": "Complaint not found."}, status=404) + 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) + except Exception: + return JsonResponse({"success": False, "error": "Not found."}, status=404) - if complaint.status not in ("resolved", "closed") or not complaint.resolution: - return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved complaints."}, status=400) + if obj.status not in ("resolved", "closed"): + return JsonResponse({"success": False, "error": "Satisfaction can only be set for resolved items."}, status=400) - complaint.satisfaction = satisfaction - complaint.satisfaction_set_at = timezone.now() - complaint.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"]) + obj.satisfaction = satisfaction + obj.satisfaction_set_at = timezone.now() + obj.save(update_fields=["satisfaction", "satisfaction_set_at", "updated_at"]) - return JsonResponse({"success": True, "satisfaction": complaint.satisfaction}) + return JsonResponse({"success": True, "satisfaction": obj.satisfaction}) diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index 0ea3c8f..149ab4b 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -660,7 +660,7 @@ def my_dashboard(request): # 5. QI Project Tasks from apps.projects.models import QIProjectTask - tasks_qs = QIProjectTask.objects.filter(assigned_to=user) + tasks_qs = QIProjectTask.objects.filter(assigned_to__user=user) # Filter by selected hospital for PX Admins (via project) if selected_hospital: tasks_qs = tasks_qs.filter(project__hospital=selected_hospital) diff --git a/apps/feedback/urls.py b/apps/feedback/urls.py index 6c5a9e0..82e5f35 100644 --- a/apps/feedback/urls.py +++ b/apps/feedback/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ # Workflow actions path("/assign/", views.feedback_assign, name="feedback_assign"), path("/change-status/", views.feedback_change_status, name="feedback_change_status"), + path("/send-to-department/", views.feedback_send_to_department, name="feedback_send_to_department"), path("/add-response/", views.feedback_add_response, name="feedback_add_response"), # Toggle actions path("/toggle-featured/", views.feedback_toggle_featured, name="feedback_toggle_featured"), diff --git a/apps/feedback/views.py b/apps/feedback/views.py index c5f25d8..f8ff0fd 100644 --- a/apps/feedback/views.py +++ b/apps/feedback/views.py @@ -251,7 +251,13 @@ def feedback_detail(request, pk): "attachments": attachments, "assignable_users": assignable_users, "status_choices": FeedbackStatus.choices, - "can_edit": user.is_px_admin() or user.is_hospital_admin(), + "can_edit": ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ), + "can_admin": user.is_px_admin() or user.is_hospital_admin(), "linked_rcas": linked_rcas, "content_type_id": feedback_ct.pk, "object_id": feedback.pk, @@ -692,7 +698,12 @@ def feedback_assign(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ): messages.error(request, "You don't have permission to assign this suggestion.") return redirect("feedback:feedback_detail", pk=pk) @@ -742,7 +753,12 @@ def feedback_change_status(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ): messages.error(request, "You don't have permission to change this suggestion's status.") return redirect("feedback:feedback_detail", pk=pk) @@ -1031,3 +1047,66 @@ def feedback_create_action(request, pk): messages.success(request, f"PX Action created successfully from suggestion.") return redirect("feedback:feedback_detail", pk=feedback.id) + + +@login_required +@require_http_methods(["POST"]) +def feedback_send_to_department(request, pk): + """Send suggestion to its department for awareness (no response required).""" + from apps.notifications.services import NotificationService + from django.utils import timezone + + feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ): + messages.error(request, "You don't have permission to send this suggestion.") + return redirect("feedback:feedback_detail", pk=pk) + + if not feedback.department: + messages.error(request, "No department assigned to this suggestion.") + return redirect("feedback:feedback_detail", pk=pk) + + dept = feedback.department + note = request.POST.get("note", "").strip() + + # Notify department champion and/or manager + notified = [] + for role_attr in ("champion", "manager", "deputy_manager"): + staff = getattr(dept, role_attr, None) + if staff and staff.email: + try: + NotificationService.send_email( + email=staff.email, + subject=f"Suggestion Notification - {feedback.title or 'Untitled'}", + message=( + f"Dear {staff.get_full_name()},\n\n" + f"A suggestion has been logged for your department ({dept.name}):\n\n" + f"Title: {feedback.title or 'Untitled'}\n" + f"Category: {feedback.get_category_display()}\n" + f"Message: {feedback.message[:500]}\n\n" + f"This is for your awareness. No response is required.\n\n" + f"{'Additional note: ' + note if note else ''}" + ), + related_object=feedback, + user=staff.user if hasattr(staff, "user") and staff.user else None, + ) + notified.append(staff.get_full_name()) + except Exception: + pass + + FeedbackResponse.objects.create( + feedback=feedback, + response_type="note", + message=f"Suggestion sent to {dept.name}" + (f" — notified: {', '.join(notified)}" if notified else ""), + created_by=request.user, + is_internal=False, + ) + + messages.success(request, f"Suggestion sent to {dept.name}.") + return redirect("feedback:feedback_detail", pk=pk) diff --git a/apps/observations/migrations/0017_observation_responded_at_observation_responded_by_and_more.py b/apps/observations/migrations/0017_observation_responded_at_observation_responded_by_and_more.py new file mode 100644 index 0000000..ec9f141 --- /dev/null +++ b/apps/observations/migrations/0017_observation_responded_at_observation_responded_by_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.1 on 2026-06-16 16:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0016_observation_observation_status_valid'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='responded_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='observation', + name='responded_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='responded_observations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='observation', + name='response', + field=models.TextField(blank=True, help_text='Patient-facing response text'), + ), + migrations.AddField( + model_name='observation', + name='response_ar', + field=models.TextField(blank=True, help_text='Response text (Arabic)'), + ), + migrations.AddField( + model_name='observation', + name='response_en', + field=models.TextField(blank=True, help_text='Response text (English)'), + ), + migrations.AddField( + model_name='observation', + name='response_sent_at', + field=models.DateTimeField(blank=True, help_text='When response was sent to reporter', null=True), + ), + ] diff --git a/apps/observations/migrations/0018_observation_satisfaction_and_more.py b/apps/observations/migrations/0018_observation_satisfaction_and_more.py new file mode 100644 index 0000000..139073d --- /dev/null +++ b/apps/observations/migrations/0018_observation_satisfaction_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-06-16 17:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0017_observation_responded_at_observation_responded_by_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='observation', + name='satisfaction', + field=models.CharField(blank=True, choices=[('satisfied', 'Satisfied'), ('neutral', 'Neutral'), ('dissatisfied', 'Dissatisfied'), ('no_response', 'No Response')], default='', max_length=20), + ), + migrations.AddField( + model_name='observation', + name='satisfaction_set_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/apps/observations/models.py b/apps/observations/models.py index 74b53ec..1870900 100644 --- a/apps/observations/models.py +++ b/apps/observations/models.py @@ -587,6 +587,23 @@ class Observation(UUIDModel, TimeStampedModel, SoftDeleteModel): ) resolution_notes = models.TextField(blank=True) + # Patient-facing response (what gets sent to the reporter) + response = models.TextField(blank=True, help_text="Patient-facing response text") + response_en = models.TextField(blank=True, help_text="Response text (English)") + response_ar = models.TextField(blank=True, help_text="Response text (Arabic)") + response_sent_at = models.DateTimeField(null=True, blank=True, help_text="When response was sent to reporter") + responded_at = models.DateTimeField(null=True, blank=True) + responded_by = models.ForeignKey( + 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 e0a6be9..7b956b9 100644 --- a/apps/observations/urls.py +++ b/apps/observations/urls.py @@ -55,6 +55,10 @@ urlpatterns = [ path("/reopen/", views.observation_reopen, name="observation_reopen"), # Add note 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"), # Escalate observation diff --git a/apps/observations/views.py b/apps/observations/views.py index cc2323b..053a7a7 100644 --- a/apps/observations/views.py +++ b/apps/observations/views.py @@ -629,12 +629,13 @@ def observation_detail(request, pk): "note_form": note_form, "status_choices": ObservationStatus.choices, "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin(), - "can_convert": user.is_px_admin() or user.is_hospital_admin(), - "can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management(), - "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or (user.is_champion() and observation.assigned_department == user.department), - "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(), - "can_send_reminder": user.is_px_admin() or user.is_hospital_admin(), - "can_delete": user.is_px_admin() or user.is_hospital_admin(), + "can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), + "can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_department_manager() or user.is_px_management(), + "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or (user.is_champion() and observation.assigned_department == user.department), + "can_review_dept_response": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), + "can_send_reminder": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), + "can_delete": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), + "can_admin": user.is_px_admin() or user.is_hospital_admin(), "linked_rcas": linked_rcas, } @@ -771,7 +772,10 @@ def observation_assign(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to assign observations.") return redirect("observations:observation_detail", pk=pk) @@ -824,7 +828,10 @@ def observation_activate(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, _("You don't have permission to activate observations.")) return redirect("observations:observation_detail", pk=pk) @@ -874,7 +881,10 @@ def observation_reopen(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to reopen observations.") return redirect("observations:observation_detail", pk=pk) @@ -930,6 +940,160 @@ def observation_add_note(request, pk): return redirect("observations:observation_detail", pk=pk) +@login_required +@require_http_methods(["POST"]) +def observation_respond(request, pk): + """Respond to observation with patient-facing response text.""" + observation = get_object_or_404(Observation, pk=pk) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or observation.assigned_to == user + ): + messages.error(request, _("You don't have permission to respond to observations.")) + return redirect("observations:observation_detail", pk=pk) + + response = request.POST.get("response", "").strip() + + if not response: + messages.error(request, "Please enter a response.") + return redirect("observations:observation_detail", pk=pk) + + observation.response = response + observation.response_en = "" + observation.response_ar = "" + observation.responded_at = timezone.now() + observation.responded_by = request.user + observation.response_sent_at = timezone.now() + if observation.status not in ("resolved", "closed"): + observation.status = "resolved" + observation.resolved_at = timezone.now() + observation.resolved_by = request.user + observation.save() + + from apps.core.services import AuditService + AuditService.log_event( + event_type="observation_responded", + description=f"Response sent for observation {observation.tracking_code or observation.id}", + user=request.user, + content_object=observation, + ) + + messages.success(request, _("Response sent successfully.")) + return redirect("observations:observation_detail", pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def observation_generate_ai_response(request, pk): + """Generate AI-powered response for an observation in both English and Arabic.""" + from django.http import JsonResponse + from apps.core.ai_service import AIService + from apps.core.services import AuditService + import json, logging + + logger = logging.getLogger(__name__) + observation = get_object_or_404(Observation, pk=pk) + + user = request.user + if not ( + user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + or observation.assigned_to == user + ): + return JsonResponse({"error": "You don't have permission."}, status=403) + + try: + ai_desc_en = "" + ai_desc_ar = "" + if observation.metadata and "ai_analysis" in observation.metadata: + ai_desc_en = observation.metadata["ai_analysis"].get("short_description_en", "") + ai_desc_ar = observation.metadata["ai_analysis"].get("short_description_ar", "") + + dept_section = "" + if observation.department_response_en or observation.department_response_ar: + dept_en = observation.department_response_en or "" + dept_ar = observation.department_response_ar or "" + dept_section = f""" +DEPARTMENT RESPONSE (use this as the primary basis): +- English: {dept_en} +- Arabic: {dept_ar} + +Transform the department response into a clear, patient-friendly response.""" + + prompt = f"""As a healthcare observation response specialist, generate a professional response to this observation in BOTH English and Arabic. + +OBSERVATION DETAILS: +- Description: {observation.description} +- Category: {observation.category.name if observation.category else 'General'} +- Hospital: {observation.hospital.name if observation.hospital else 'Unknown'} + +AI SUMMARY (for context): +- English: {ai_desc_en} +- Arabic: {ai_desc_ar} +{dept_section} + +Generate a professional response that: +1. Acknowledges the observation and thanks the reporter +2. Addresses what was observed +3. Explains any actions taken or planned +4. Uses a professional, empathetic tone + +IMPORTANT: Provide the response in BOTH languages as JSON: +{{ + "response_en": "The response text in English (2-4 paragraphs)", + "response_ar": "نص الرد بالعربية (2-4 فقرات)" +}}""" + + system_prompt = """You are an expert healthcare observation response specialist fluent in both English and Arabic. +Generate comprehensive, professional responses in both languages. Use Modern Standard Arabic (Fusha).""" + + ai_response = AIService.chat_completion( + prompt=prompt, + system_prompt=system_prompt, + temperature=0.4, + max_tokens=1500, + response_format="json_object", + ) + + response_data = json.loads(ai_response) + response_en = response_data.get("response_en", "").strip() + response_ar = response_data.get("response_ar", "").strip() + + AuditService.log_event( + event_type="ai_observation_response_generated", + description=f"AI response generated for observation {observation.tracking_code}", + user=request.user, + content_object=observation, + ) + + return JsonResponse({"success": True, "response_en": response_en, "response_ar": response_ar}) + + except Exception as e: + logger.error(f"AI observation response generation failed: {e}") + 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): @@ -940,7 +1104,10 @@ def observation_convert_to_action(request, pk): # Check permission user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to convert observations to actions.") return redirect("observations:observation_detail", pk=pk) @@ -1006,7 +1173,7 @@ def observation_send_to_department(request, pk): user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to send observations to departments.")) return redirect("observations:observation_detail", pk=pk) @@ -1161,7 +1328,7 @@ def observation_escalate(request, pk): if not ( user.is_px_admin() or user.is_hospital_admin() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to escalate observations.")) return redirect("observations:observation_detail", pk=pk) @@ -1265,7 +1432,7 @@ def observation_send_to(request, pk): user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() - or user.is_px_management() + or user.is_px_management() or user.is_px_employee() ): return JsonResponse({ "success": False, @@ -1565,7 +1732,10 @@ def observation_review_dept_response(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to review department responses.") return redirect("observations:observation_detail", pk=pk) @@ -1686,7 +1856,10 @@ def observation_send_dept_response_reminder(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user - if not (user.is_px_admin() or user.is_hospital_admin()): + if not ( + user.is_px_admin() or user.is_hospital_admin() + or user.is_px_management() or user.is_px_employee() + ): messages.error(request, "You don't have permission to send reminders.") return redirect("observations:observation_detail", pk=pk) @@ -1890,7 +2063,10 @@ def get_client_ip(request): @require_http_methods(["POST"]) def observation_soft_delete(request, pk): observation = get_object_or_404(Observation, pk=pk) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to delete observations.")) observation.soft_delete(user=request.user) messages.success(request, _("Observation moved to trash.")) @@ -1901,7 +2077,10 @@ def observation_soft_delete(request, pk): @require_http_methods(["POST"]) def observation_restore(request, pk): observation = get_object_or_404(Observation.all_objects, pk=pk, is_deleted=True) - if not (request.user.is_px_admin() or request.user.is_hospital_admin()): + if not ( + request.user.is_px_admin() or request.user.is_hospital_admin() + or request.user.is_px_management() or request.user.is_px_employee() + ): return HttpResponseForbidden(_("You don't have permission to restore observations.")) observation.restore() messages.success(request, _("Observation restored successfully.")) diff --git a/apps/organizations/serializers.py b/apps/organizations/serializers.py index 7243e60..e02c61e 100644 --- a/apps/organizations/serializers.py +++ b/apps/organizations/serializers.py @@ -112,6 +112,8 @@ class DepartmentSerializer(serializers.ModelSerializer): "phone", "email", "location", + "sub_location", + "floor", "status", "created_at", "updated_at", diff --git a/apps/organizations/ui_views.py b/apps/organizations/ui_views.py index 401245a..4a75880 100644 --- a/apps/organizations/ui_views.py +++ b/apps/organizations/ui_views.py @@ -2021,7 +2021,7 @@ def department_detail(request, pk): ).order_by("first_name", "last_name") pending_actions = [] - from apps.complaints.models import ComplaintExplanation, ComplaintInvolvedDepartment + from apps.complaints.models import ComplaintExplanation, ComplaintInvolvedDepartment, ChampionInvestigation, InvestigationResponse from django.utils import timezone as dj_tz # 1. Complaint Department Responses (new) @@ -2051,7 +2051,13 @@ def department_detail(request, pk): "department_name": pc.department.name, }) - if user.is_department_manager() or user.is_px_admin() or user.is_hospital_admin(): + if ( + user.is_department_manager() + or user.is_px_admin() + or user.is_hospital_admin() + or user.is_px_management() + or user.is_px_employee() + ): pending_manager_reviews = ComplaintInvolvedDepartment.objects.filter( department=department, response_submitted=True, @@ -2196,6 +2202,25 @@ def department_detail(request, pk): ), "pending_actions": pending_actions, "pending_actions_count": len(pending_actions), + "active_investigations": ChampionInvestigation.objects.filter( + involved_department__department=department, + status__in=["questions_sent", "answers_received"], + ).select_related("complaint", "champion", "explanation").prefetch_related( + "responses__staff", "questions" + ), + "my_pending_responses": ( + InvestigationResponse.objects.filter( + staff__user=user, + is_completed=False, + investigation__involved_department__department=department, + ).select_related("staff", "investigation__complaint", "investigation__champion") + if hasattr(user, "staff_profile") and user.staff_profile else [] + ), + "my_assigned_complaints": Complaint.objects.filter( + assigned_to=user, + status__in=["open", "in_progress"], + involved_departments__department=department, + ).distinct().select_related("department", "assigned_to")[:5], } return render(request, "organizations/department_detail.html", context) diff --git a/apps/projects/apps.py b/apps/projects/apps.py index 19882f7..d66c000 100644 --- a/apps/projects/apps.py +++ b/apps/projects/apps.py @@ -8,3 +8,6 @@ class ProjectsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.projects' verbose_name = 'Projects' + + def ready(self): + import apps.projects.signals # noqa: F401 diff --git a/apps/projects/forms.py b/apps/projects/forms.py index cd5fbb3..e6d862f 100644 --- a/apps/projects/forms.py +++ b/apps/projects/forms.py @@ -72,12 +72,14 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm): ), "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" + "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": "", } ), "team_members": 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 h-40" + "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": "", } ), "status": forms.Select( @@ -129,15 +131,17 @@ class QIProjectForm(HospitalFieldMixin, forms.ModelForm): hospital_id=hospital_id, status="active" ).order_by("name") - # Filter user choices based on hospital - from apps.core.utils import get_assignable_users - assignable = get_assignable_users(Hospital.objects.get(pk=hospital_id)) if hospital_id else User.objects.none() - self.fields["project_lead"].queryset = assignable - self.fields["team_members"].queryset = assignable + # Filter staff choices 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 = User.objects.none() - self.fields["team_members"].queryset = User.objects.none() + self.fields["project_lead"].queryset = Staff.objects.none() if False else [] + self.fields["team_members"].queryset = [] class QIProjectTaskForm(forms.ModelForm): @@ -242,8 +246,10 @@ class QIProjectTaskForm(forms.ModelForm): # Filter assigned_to choices based on project hospital if self.project and self.project.hospital: - from apps.core.utils import get_assignable_users - self.fields["assigned_to"].queryset = get_assignable_users(self.project.hospital) + from apps.organizations.models import Staff + self.fields["assigned_to"].queryset = Staff.objects.filter( + hospital=self.project.hospital, status="active" + ).order_by("first_name", "last_name") else: self.fields["assigned_to"].queryset = User.objects.none() @@ -400,11 +406,13 @@ class ConvertToProjectForm(forms.Form): ).order_by("name") # Filter project lead by hospital - from apps.core.utils import get_assignable_users - self.fields["project_lead"].queryset = get_assignable_users(self.user.hospital) + from apps.organizations.models import Staff + self.fields["project_lead"].queryset = Staff.objects.filter( + hospital=self.user.hospital, status="active" + ).order_by("first_name", "last_name") else: self.fields["template"].queryset = QIProject.objects.none() - self.fields["project_lead"].queryset = User.objects.none() + self.fields["project_lead"].queryset = Staff.objects.none() # Inline formset for task templates (used with QIProject templates) diff --git a/apps/projects/migrations/0003_alter_qiproject_project_lead_and_more.py b/apps/projects/migrations/0003_alter_qiproject_project_lead_and_more.py new file mode 100644 index 0000000..c4b1e1d --- /dev/null +++ b/apps/projects/migrations/0003_alter_qiproject_project_lead_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-06-16 20:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0014_remove_department_manager_1st'), + ('projects', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='qiproject', + name='project_lead', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='led_qi_projects', to='organizations.staff'), + ), + migrations.AlterField( + model_name='qiproject', + name='team_members', + field=models.ManyToManyField(blank=True, related_name='qi_project_memberships', to='organizations.staff'), + ), + migrations.AlterField( + model_name='qiprojecttask', + name='assigned_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qi_tasks', to='organizations.staff'), + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 6f0fe80..03417ec 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -61,7 +61,9 @@ class QIProject(UUIDModel, TimeStampedModel): ) # Project lead - project_lead = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, related_name="led_projects") + project_lead = models.ForeignKey( + "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="led_qi_projects" + ) # Creator created_by = models.ForeignKey( @@ -69,7 +71,7 @@ class QIProject(UUIDModel, TimeStampedModel): ) # Team members - team_members = models.ManyToManyField("accounts.User", blank=True, related_name="qi_projects") + team_members = models.ManyToManyField("organizations.Staff", blank=True, related_name="qi_project_memberships") # Status status = models.CharField( @@ -137,7 +139,7 @@ class QIProjectTask(UUIDModel, TimeStampedModel): # Assignment assigned_to = models.ForeignKey( - "accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_tasks" + "organizations.Staff", on_delete=models.SET_NULL, null=True, blank=True, related_name="qi_tasks" ) # Status diff --git a/apps/projects/signals.py b/apps/projects/signals.py new file mode 100644 index 0000000..bfde4c5 --- /dev/null +++ b/apps/projects/signals.py @@ -0,0 +1,38 @@ +""" +Signals for QI Projects — sends a notification when a task is assigned. +""" + +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender="projects.QIProjectTask") +def notify_task_assignment(sender, instance, created, **kwargs): + """Send an in-app notification when a QI task is assigned to a staff member.""" + if not instance.assigned_to: + return + + # Only notify on new assignments (creation with an assignee) + if not created: + return + + user = getattr(instance.assigned_to, "user", None) + if not user: + return + + try: + from apps.notifications.services import create_in_app_notification + + create_in_app_notification( + user=user, + title=f"New QI Task: {instance.title}", + message=f"You have been assigned a task in project '{instance.project.name}'.", + notification_type="qi_task_assigned", + action_url=f"/projects/{instance.project.id}/", + ) + except Exception as e: + logger.warning(f"Failed to send QI task notification: {e}") diff --git a/apps/projects/ui_views.py b/apps/projects/ui_views.py index 79fd222..c78647c 100644 --- a/apps/projects/ui_views.py +++ b/apps/projects/ui_views.py @@ -21,6 +21,37 @@ from .forms import ConvertToProjectForm, QIProjectForm, QIProjectTaskForm, QIPro from .models import QIProject, QIProjectTask, PDCAPhase, PDCAPhaseChoices, FOCUSPhase, FOCUSPhaseChoices +@block_source_user +@login_required +def my_tasks(request): + """Show QI tasks assigned to the current user across all projects.""" + user = request.user + staff_profile = getattr(user, "staff_profile", None) + if not staff_profile: + return render(request, "projects/my_tasks.html", {"grouped": [], "total": 0}) + + tasks = ( + QIProjectTask.objects.filter(assigned_to=staff_profile, project__is_template=False) + .select_related("project", "project__hospital", "pdca_phase", "focus_phase") + .order_by("due_date", "-created_at") + ) + + grouped = {} + for t in tasks: + grouped.setdefault(t.project, []).append(t) + + return render( + request, + "projects/my_tasks.html", + { + "grouped": grouped, + "total": tasks.count(), + "pending": tasks.filter(status="pending").count(), + "completed": tasks.filter(status="completed").count(), + }, + ) + + @block_source_user @login_required def project_list(request): @@ -28,7 +59,7 @@ def project_list(request): # Exclude templates from the list queryset = ( QIProject.objects.filter(is_template=False) - .select_related("hospital", "department", "project_lead") + .select_related("hospital", "department", "project_lead", "project_lead__department") .prefetch_related("team_members", "related_actions") ) @@ -102,7 +133,7 @@ def project_detail(request, pk): project = get_object_or_404( QIProject.objects.filter(is_template=False) - .select_related("hospital", "department", "project_lead") + .select_related("hospital", "department", "project_lead", "project_lead__department") .prefetch_related("team_members", "related_actions", "tasks", "pdca_phases", "focus_phases"), pk=pk, ) @@ -566,11 +597,11 @@ def task_toggle_status(request, project_pk, task_pk, phase=None): project = get_object_or_404(QIProject, pk=project_pk, is_template=False) task = get_object_or_404(QIProjectTask, pk=task_pk, project=project) - if not (user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager()): + if not _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 permission + # 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) @@ -948,7 +979,8 @@ def pdca_phase_edit(request, pk, phase): team_members = project.team_members.all() if project.project_lead: - team_members = team_members | User.objects.filter(pk=project.project_lead.pk) + if project.project_lead not in team_members: + team_members = list(team_members) + [project.project_lead] context = { "project": project, @@ -1060,7 +1092,8 @@ def focus_phase_edit(request, pk, phase): team_members = project.team_members.all() if project.project_lead: - team_members = team_members | User.objects.filter(pk=project.project_lead.pk) + if project.project_lead not in team_members: + team_members = list(team_members) + [project.project_lead] context = { "project": project, @@ -1114,6 +1147,23 @@ def _get_can_edit(user): return user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager +def _can_manage_task(project, task, user): + """Check if user can toggle/manage a specific task. + + True for admins/managers, the task's assignee, or project team members. + """ + if _get_can_edit(user): + return True + # assignee check (task.assigned_to is a Staff; Staff.user is the linked User) + if task.assigned_to and getattr(task.assigned_to, "user_id", None) == user.id: + return True + # team member check + staff_profile = getattr(user, "staff_profile", None) + if staff_profile and project.team_members.filter(id=staff_profile.id).exists(): + return True + return False + + @block_source_user @login_required def htmx_task_toggle_status(request, project_pk, task_pk): @@ -1128,6 +1178,9 @@ def htmx_task_toggle_status(request, project_pk, task_pk): if not _check_project_permission(project, user): return HttpResponse(_("Permission denied"), status=403) + if not _can_manage_task(project, task, user): + return HttpResponse(_("Permission denied"), status=403) + if task.status == "completed": task.status = "pending" task.completed_date = None @@ -1138,12 +1191,13 @@ def htmx_task_toggle_status(request, project_pk, task_pk): task.save() can_edit = _get_can_edit(user) + can_toggle = _can_manage_task(project, task, user) today = timezone.now().date() return render( request, "projects/partials/task_row.html", - {"task": task, "project": project, "can_edit": can_edit, "today": today}, + {"task": task, "project": project, "can_edit": can_edit, "can_toggle": can_toggle, "today": today}, ) @@ -1522,7 +1576,8 @@ def htmx_phase_edit_form(request, project_pk, phase_type, phase): # GET - return form team_members = project.team_members.all() if project.project_lead: - team_members = team_members | User.objects.filter(pk=project.project_lead.pk) + if project.project_lead not in team_members: + team_members = list(team_members) + [project.project_lead] return render( request, diff --git a/apps/projects/urls.py b/apps/projects/urls.py index f299293..e704040 100644 --- a/apps/projects/urls.py +++ b/apps/projects/urls.py @@ -7,6 +7,7 @@ app_name = "projects" urlpatterns = [ # QI Project Views path("", ui_views.project_list, name="project_list"), + path("my-tasks/", ui_views.my_tasks, name="my_tasks"), path("create/", ui_views.project_create, name="project_create"), path("create/from-template//", ui_views.project_create, name="project_create_from_template"), path("/", ui_views.project_detail, name="project_detail"), diff --git a/e2e/tests/workflows/qi-projects-workflow.spec.ts b/e2e/tests/workflows/qi-projects-workflow.spec.ts new file mode 100644 index 0000000..f96458b --- /dev/null +++ b/e2e/tests/workflows/qi-projects-workflow.spec.ts @@ -0,0 +1,173 @@ +/* eslint-disable */ +/** + * QI Projects workflow — cross-department team + task management. + * + * Tests: + * 1. Admin creates a project with team from 2+ departments. + * 2. Tasks assigned to each team member. + * 3. Team member A (Contact Center) logs in → toggles their task → verified. + * 4. Team member B (different dept) logs in → toggles their task → verified cross-dept. + * 5. My Tasks view → team member sees their assigned tasks. + * 6. Admin exports Excel. + * 7. Admin closes project. + * + * Run headed: + * E2E_MAXIMIZED=1 E2E_TIMEOUT=120000 E2E_ACTION_TIMEOUT=12000 \ + * npx playwright test --headed --project chromium qi-projects-workflow --workers=1 + */ +import { test } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as path from 'path'; +import { attachObservers, observe, loginAndScope, OBS, BASE_URL } from '../../helpers/audit'; +import { RoleName } from '../../helpers/helpers'; + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); +const M = 'QIProjects'; +const ADMIN: RoleName = 'hospital_admin'; + +type Page = import('@playwright/test').Page; + +function uv(args: string): string { return execSync(args, { cwd: PROJECT_ROOT }).toString(); } + +interface SeedData { projectId: string; taskAId: string; taskBId: string; staffA: string; staffB: string; deptA: string; deptB: string; } + +function seed(): SeedData { + const out = uv('uv run manage.py seed_e2e_project'); + const m = out.match(/project_id=(\S+)\s+task_a_id=(\S+)\s+task_b_id=(\S+)\s+staff_a=(\S+)\s+staff_b=(\S+)\s+dept_a=(\S+)\s+dept_b=(\S+)/); + if (!m) throw new Error('seed parse failed: ' + out); + return { projectId: m[1], taskAId: m[2], taskBId: m[3], staffA: m[4], staffB: m[5], deptA: m[6], deptB: m[7] }; +} + +function projectState(pid: string): Record { + const out = uv(`uv run manage.py get_e2e_project_state ${pid}`); + const s: Record = {}; + for (const line of out.split('\n')) { const i = line.indexOf('='); if (i > 0) s[line.slice(0, i)] = line.slice(i + 1); } + return s; +} + +async function csrfOf(page: Page): Promise { + return page.context().cookies().then((c) => c.find((x) => x.name === 'csrftoken')?.value || ''); +} +async function postForm(page: Page, url: string, data: Record) { + const csrf = await csrfOf(page); + return page.context().request.post(url, { + maxRedirects: 0, + headers: { 'X-Requested-With': 'XMLHttpRequest', ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + form: { csrfmiddlewaretoken: csrf, ...data }, + }); +} +async function login(page: Page, role: RoleName) { + await page.context().clearCookies(); + await loginAndScope(page, role, M); +} + +// Role name by email for the e2e accounts +function roleForEmail(email: string): RoleName { + if (email.includes('staff')) return 'staff'; + if (email.includes('nurse')) return 'nurse'; + return 'staff'; +} + +test('QI Projects: cross-dept team members can toggle their tasks', async ({ page }) => { + attachObservers(page, M, ADMIN); + const s = seed(); + observe(M, 'seed', 'INFO', `project ${s.projectId}, tasks A=${s.taskAId.slice(0,8)} B=${s.taskBId.slice(0,8)}`, {}); + + try { + // ── 1. Admin views project + verifies team + tasks ───────────────────── + await login(page, ADMIN); + await page.goto(`${BASE_URL}/projects/${s.projectId}/`); + await page.waitForLoadState('domcontentloaded'); + const tb1 = await page.textContent('body').catch(() => ''); + const projectLoads = tb1!.includes('E2E QI Project') || !page.url().includes('login'); + observe(M, '1-admin-view', projectLoads ? 'PASS' : 'FAIL', `project detail loads for admin`, { role: ADMIN, url: page.url() }); + + const st0 = projectState(s.projectId); + observe(M, '1-state', st0.team_count === '2' ? 'PASS' : 'FAIL', `team_count=${st0.team_count} (want 2)`, { role: ADMIN }); + observe(M, '1-tasks', st0.task_count === '2' ? 'PASS' : 'FAIL', `task_count=${st0.task_count} (want 2)`, { role: ADMIN }); + + // ── 2. Team member A (Contact Center) toggles their task ─────────────── + const roleA = roleForEmail(s.staffA); + await login(page, roleA); + await page.goto(`${BASE_URL}/projects/${s.projectId}/`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(500); + const aLoads = !page.url().includes('login'); + observe(M, '2-staffA-view', aLoads ? 'PASS' : 'FAIL', `${roleA} can view project (cross-dept OK)`, { role: roleA, url: page.url() }); + + // toggle task A via POST (the htmx endpoint or standard endpoint) + const toggleA = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskAId}/toggle/`, {}); + observe(M, '2-staffA-toggle', toggleA.status() < 400 ? 'PASS' : 'FAIL', + `staff A toggling their task: HTTP ${toggleA.status()}`, { role: roleA, http: toggleA.status() }); + const stA = projectState(s.projectId); + const taskAKey = `task_${s.taskAId.slice(0,8)}_status`; + observe(M, '2-staffA-state', stA[taskAKey] === 'completed' ? 'PASS' : 'FAIL', + `task A status=${stA[taskAKey]} (want completed)`, { role: roleA }); + + // ── 3. Team member B (different dept) toggles their task ─────────────── + const roleB = roleForEmail(s.staffB); + await login(page, roleB); + await page.goto(`${BASE_URL}/projects/${s.projectId}/`); + await page.waitForLoadState('domcontentloaded'); + await page.waitForTimeout(500); + const bLoads = !page.url().includes('login'); + observe(M, '3-staffB-view', bLoads ? 'PASS' : 'FAIL', `${roleB} from a DIFFERENT dept can view project`, { role: roleB, url: page.url() }); + + const toggleB = await postForm(page, `${BASE_URL}/projects/${s.projectId}/htmx/tasks/${s.taskBId}/toggle/`, {}); + observe(M, '3-staffB-toggle', toggleB.status() < 400 ? 'PASS' : 'FAIL', + `staff B toggling their task: HTTP ${toggleB.status()}`, { role: roleB, http: toggleB.status() }); + const stB = projectState(s.projectId); + const taskBKey = `task_${s.taskBId.slice(0,8)}_status`; + observe(M, '3-staffB-state', stB[taskBKey] === 'completed' ? 'PASS' : 'FAIL', + `task B status=${stB[taskBKey]} (want completed)`, { role: roleB }); + + // ── 4. My Tasks view ─────────────────────────────────────────────────── + await page.goto(`${BASE_URL}/projects/my-tasks/`); + await page.waitForLoadState('domcontentloaded'); + const myTasksBody = await page.textContent('body').catch(() => ''); + const hasMyTasks = !page.url().includes('login') && (myTasksBody!.includes('QI') || myTasksBody!.includes('task') || myTasksBody!.includes('no QI')); + observe(M, '4-my-tasks', hasMyTasks ? 'PASS' : 'FAIL', `My Tasks page loads for ${roleB}`, { role: roleB, url: page.url() }); + + // ── 5. Admin exports Excel ───────────────────────────────────────────── + await login(page, ADMIN); + const exportResp = await page.context().request.get(`${BASE_URL}/projects/${s.projectId}/export/excel/`); + const exportOk = exportResp.status() === 200; + const ct = exportResp.headers()['content-type'] || ''; + observe(M, '5-export', exportOk ? 'PASS' : 'FAIL', + `Excel export: HTTP ${exportResp.status()} ct=${ct}`, { role: ADMIN, http: exportResp.status() }); + + // ── 6. Admin closes project ──────────────────────────────────────────── + // Edit the project status to completed + const csrf = await csrfOf(page); + await page.context().request.post(`${BASE_URL}/projects/${s.projectId}/edit/`, { + maxRedirects: 0, + headers: { ...(csrf ? { 'X-CSRFToken': csrf } : {}) }, + form: { + csrfmiddlewaretoken: csrf, + name: `E2E QI Project (closed)`, + description: 'Closed by E2E test', + hospital: projectState(s.projectId).project_status || '', + status: 'completed', + start_date: '', + target_completion_date: '', + outcome_description: 'E2E test completed successfully', + }, + }).catch(() => {}); + const stFinal = projectState(s.projectId); + observe(M, '6-close', stFinal.project_status === 'completed' ? 'PASS' : 'WARN', + `project_status=${stFinal.project_status} (want completed)`, { role: ADMIN }); + + observe(M, 'flow-complete', 'PASS', + `QI project ${s.projectId}: cross-dept team toggled tasks, My Tasks viewed, Excel exported`, {}); + + } catch (e) { + observe(M, 'flow', 'FAIL', `exception: ${(e as Error).message}`, {}); + } +}); + +test.afterAll(async () => { + const counts = OBS.reduce>((a, o) => ((a[o.status] = (a[o.status] || 0) + 1), a), {}); + console.log('\n=========== QI PROJECTS SUMMARY ==========='); + console.log('Total observations:', OBS.length, JSON.stringify(counts)); + console.log('===========================================\n'); +}); diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index 43080a1..497ce65 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -62,6 +62,9 @@ .inner-tab-inactive { color: #64748b; } + .ts-dropdown { + z-index: 9999 !important; + } {% endblock %} @@ -159,11 +162,6 @@ {% trans "Departments" %} ({{ complaint.involved_departments_count }}) {% if not complaint.assigned_to == current_user and complaint.is_active_status %}{% endif %} - {% endcomment %} - - - - {% comment %} {% endcomment %} - - @@ -233,9 +223,10 @@
-
-
+
+
{% if complaint.description %} +

{% trans "Complaint Description" %}

"{{ complaint.description }}"

@@ -243,13 +234,77 @@
-

{% trans "Location" %}

+
+

{% trans "Location" %}

+ {% if can_manage_actions and complaint.is_active_status %} + + {% endif %} +
+ {% if complaint.department or complaint.location_type or complaint.area or complaint.section %} + +
+ +

+ {% if complaint.department %}{{ complaint.department.get_localized_name }}{% else %}-{% endif %} +

+
+ + {% if complaint.location_type %} +
+ + + {{ complaint.get_location_type_display }} + +
+ {% endif %} + + {% if complaint.area or complaint.department.category or complaint.section or complaint.zone or complaint.floor %} +
+ {% if complaint.area %} +
+
{% trans "Area" %}
+
{% if LANG == 'ar' and complaint.area.name_ar %}{{ complaint.area.name_ar }}{% else %}{{ complaint.area.name_en }}{% endif %}
+
+ {% endif %} + {% if complaint.department and complaint.department.category %} +
+
{% trans "Category" %}
+
{{ complaint.department.get_category_display }}
+
+ {% endif %} + {% if complaint.section %} +
+
{% trans "Section" %}
+
{{ complaint.section.get_localized_name }}
+
+ {% endif %} + {% if complaint.zone %} +
+
{% trans "Zone" %}
+
{{ complaint.zone }}
+
+ {% endif %} + {% if complaint.floor %} +
+
{% trans "Floor" %}
+
{{ complaint.floor }}
+
+ {% endif %} +
+ {% endif %} + + {% else %}

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

{% if complaint.legacy_main_section %}

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

{% endif %} + {% endif %}

{% trans "Severity" %}

@@ -371,16 +426,16 @@
{% endif %}
+ {% include "complaints/partials/pdf_summary_panel.html" %}
- - - - - - - - - - + @@ -418,18 +469,9 @@ {% include "complaints/partials/adverse_actions_panel.html" %}
- - - - -
@@ -440,7 +482,7 @@

{% trans "Quick Actions" %}

{% if can_edit and complaint.is_active_status %} - {% if current_user.is_px_admin or current_user.is_hospital_admin or complaint.assigned_to == current_user %} + {% if can_manage_actions or complaint.assigned_to == current_user %} - {% if complaint.assigned_to == current_user %} - + {% if complaint.assigned_to == current_user or can_manage_actions %} + {% else %} - {% if current_user.is_px_admin or current_user.is_hospital_admin %} + {% if can_manage_actions %}
{% csrf_token %} @@ -547,157 +589,6 @@ {% endif %}
- - - {% if can_edit and available_transitions and complaint.assigned_to == current_user %} - {% if current_user.is_px_admin or current_user.is_hospital_admin %} -
-

- - {% trans "Update Status" %} -

- - {% csrf_token %} -
- -
-
- - -
- - -
- {% endif %} - {% endif %} - - - {% if complaint.is_active_status and complaint.delay_reason_closure %} -
-

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

-
-

{{ complaint.get_delay_reason_closure_display }}

-
- {% if can_edit %} -
- {% csrf_token %} - - -
- {% endif %} -
- {% endif %} - - - {% if complaint.is_activated %} -
-

- {% trans "Staff Assignment" %} ({{ complaint.involved_staff_count }}) -

- {% if complaint.involved_staff.exists %} -
- {% for staff_inv in complaint.involved_staff.all|slice:":3" %} -
-
- {{ staff_inv.staff.first_name|first }}{{ staff_inv.staff.last_name|first }} -
-
-

{{ staff_inv.staff }}

-

{{ staff_inv.get_role_display }}

-
-
- {% endfor %} - {% if complaint.involved_staff_count > 3 %} - - {% endif %} -
- {% else %} -
-
- -
-

- {% trans "No staff assigned to this case yet." %} -

- {% if can_edit and complaint.is_active_status %} - - {% trans "Select Staff" %} - - {% endif %} -
- {% endif %} -
- {% endif %} - - - {% if complaint.is_activated %} -
-
- -

{% trans "Assignment Info" %}

-
-
    -
  • - {% trans "Main Dept:" %} - {{ complaint.department.name|default:"-" }} -
  • -
  • - {% trans "Assigned To:" %} - {{ complaint.assigned_to.get_full_name|default:"Unassigned" }} -
  • -
  • - {% trans "TAT Goal:" %} - {{ complaint.due_at|timeuntil }} -
  • -
  • - {% trans "Status:" %} - {{ complaint.get_status_display }} -
  • -
-
- {%endif%} - - - {% if complaint.involved_departments_count > 0 %} -
-

- {% trans "Involved Departments" %} ({{ complaint.involved_departments_count }}) -

-
- {% for dept in complaint.involved_departments.all %} -
- - - {{ dept.department.name }} - - {% if dept.is_primary %} - {% trans "PRIMARY" %} - {% endif %} -
- {% endfor %} -
-
- {% endif %} {% if complaint.status != 'open' %} @@ -939,6 +830,225 @@ + + + +{% if can_manage_actions and complaint.is_active_status %} + + + + + +{% endif %} + -{% include "components/send_to_modal.html" with users=assignable_users departments=hospital_departments %} +{% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments email_subject=send_to_email_subject email_body=send_to_email_body %} {% include "components/department_response_modal.html" %} {% endblock %} diff --git a/templates/complaints/complaint_form.html b/templates/complaints/complaint_form.html index ebe7db3..25b1ca4 100644 --- a/templates/complaints/complaint_form.html +++ b/templates/complaints/complaint_form.html @@ -933,7 +933,7 @@ document.addEventListener('DOMContentLoaded', function() { return; } patientLookupTimer = setTimeout(function () { - fetch(`/complaints/api/lookup-patient/?national_id=${encodeURIComponent(val)}`) + fetch(`/complaints/public/api/lookup-patient/?national_id=${encodeURIComponent(val)}`) .then(response => response.json()) .then(data => { if (data.found) { diff --git a/templates/complaints/complaint_pdf.html b/templates/complaints/complaint_pdf.html index 525dd8b..6d75e05 100644 --- a/templates/complaints/complaint_pdf.html +++ b/templates/complaints/complaint_pdf.html @@ -598,7 +598,7 @@ {% if explanations %}
-

💬 {% trans "Staff Explanations" %}

+

💬 {% trans "Send To Department" %}

{% for exp in explanations %}
diff --git a/templates/complaints/inquiry_detail.html b/templates/complaints/inquiry_detail.html index be5042c..493f012 100644 --- a/templates/complaints/inquiry_detail.html +++ b/templates/complaints/inquiry_detail.html @@ -82,7 +82,7 @@ {% trans "Inquiries" %} {% endif %} - {{ inquiry.reference_number|truncatechars:15 }} + {{ inquiry.reference_number }} - + {% if can_admin %} + {% endif %}
- - - -
- - -
- -
- - + placeholder="{% trans 'Enter your response...' %}" required>{{ inquiry.response|default:'' }}

- {% trans "At least one language is required. The response will be sent to the inquirer via SMS and Email." %} + {% trans "The response will be sent to the inquirer via SMS and Email." %}

@@ -1085,21 +1012,18 @@ function generateAIResponse() { } function useAISuggestion(lang) { + var text = ''; + var card = null; if (lang === 'en') { - document.getElementById('responseEn').value = document.getElementById('aiSuggestionEnText').textContent; - document.getElementById('aiSuggestionEn').classList.add('selected'); - setTimeout(() => document.getElementById('aiSuggestionEn').classList.remove('selected'), 1500); + text = document.getElementById('aiSuggestionEnText').textContent; + card = document.getElementById('aiSuggestionEn'); } else { - document.getElementById('responseAr').value = document.getElementById('aiSuggestionArText').textContent; - document.getElementById('aiSuggestionAr').classList.add('selected'); - setTimeout(() => document.getElementById('aiSuggestionAr').classList.remove('selected'), 1500); + text = document.getElementById('aiSuggestionArText').textContent; + card = document.getElementById('aiSuggestionAr'); } -} - -function useBothAISuggestions() { - useAISuggestion('en'); useAISuggestion('ar'); - document.getElementById('aiSuggestionEn').classList.add('selected'); - document.getElementById('aiSuggestionAr').classList.add('selected'); + document.getElementById('responseText').value = text; + card.classList.add('selected'); + setTimeout(() => card.classList.remove('selected'), 1500); } function reanalyzeAI() { @@ -1154,7 +1078,7 @@ document.addEventListener('keydown', function(e) { }); -{% include "components/send_to_modal.html" with users=assignable_users departments=hospital_departments %} +{% include "components/send_to_modal.html" with users=send_to_users departments=hospital_departments %} {% include "components/department_response_modal.html" %} {% endblock %} diff --git a/templates/complaints/inquiry_form.html b/templates/complaints/inquiry_form.html index 9de4710..5ee1cbc 100644 --- a/templates/complaints/inquiry_form.html +++ b/templates/complaints/inquiry_form.html @@ -444,7 +444,7 @@ document.addEventListener('DOMContentLoaded', function() { return; } patientLookupTimer = setTimeout(function () { - fetch(`/complaints/api/lookup-patient/?phone=${encodeURIComponent(val)}`) + fetch(`/complaints/public/api/lookup-patient/?phone=${encodeURIComponent(val)}`) .then(response => response.json()) .then(data => { if (data.found) { diff --git a/templates/complaints/investigation_already_started.html b/templates/complaints/investigation_already_started.html index 2da8255..d5263d1 100644 --- a/templates/complaints/investigation_already_started.html +++ b/templates/complaints/investigation_already_started.html @@ -15,8 +15,8 @@
-
- +
+
diff --git a/templates/complaints/investigation_already_submitted.html b/templates/complaints/investigation_already_submitted.html index eddcbda..e74c87e 100644 --- a/templates/complaints/investigation_already_submitted.html +++ b/templates/complaints/investigation_already_submitted.html @@ -15,8 +15,8 @@
-
- +
+
diff --git a/templates/complaints/investigation_questions.html b/templates/complaints/investigation_questions.html index 3d244e0..68c47ce 100644 --- a/templates/complaints/investigation_questions.html +++ b/templates/complaints/investigation_questions.html @@ -11,33 +11,33 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); body { font-family: 'Inter', sans-serif; } .page-header-gradient { - background: linear-gradient(135deg, #b45309 0%, #d97706 50%, #f59e0b 100%); + background: linear-gradient(135deg, #005696 0%, #0069a8 50%, #007bbd 100%); color: white; padding: 1.5rem 2rem; border-radius: 1rem; margin-bottom: 1.5rem; - box-shadow: 0 10px 15px -3px rgba(217, 119, 6, 0.2); + box-shadow: 0 10px 15px -3px rgba(0, 86, 150, 0.2); } .form-section { background: #fff; border: 2px solid #e2e8f0; border-radius: 1rem; padding: 1.5rem; margin-bottom: 1.5rem; } - .form-section:hover { border-color: #d97706; box-shadow: 0 4px 12px rgba(217, 119, 6, 0.1); } + .form-section:hover { border-color: #005696; box-shadow: 0 4px 12px rgba(0, 86, 150, 0.1); } .form-label { display: block; font-size: 0.875rem; font-weight: 600; color: #1e293b; margin-bottom: 0.5rem; } .form-control { width: 100%; padding: 0.75rem 1rem; border: 2px solid #e2e8f0; border-radius: 0.75rem; font-size: 0.875rem; transition: all 0.2s ease; } - .form-control:focus { outline: none; border-color: #d97706; box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.1); } + .form-control:focus { outline: none; border-color: #005696; box-shadow: 0 0 0 3px rgba(0, 86, 150, 0.1); } .btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; - padding: 0.75rem 1.5rem; background: #d97706; color: white; border-radius: 0.75rem; + padding: 0.75rem 1.5rem; background: #005696; color: white; border-radius: 0.75rem; font-weight: 600; transition: all 0.2s ease; border: none; cursor: pointer; width: 100%; } - .btn-primary:hover { background: #b45309; } + .btn-primary:hover { background: #007bbd; } .btn-add { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - background: #fef3c7; color: #92400e; border: 2px dashed #fbbf24; border-radius: 0.75rem; + background: #eef6fb; color: #005696; border: 2px dashed #93c5fd; border-radius: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s; font-size: 0.875rem; } - .btn-add:hover { background: #fde68a; border-color: #f59e0b; } + .btn-add:hover { background: #dbeaf6; border-color: #005696; } .btn-remove { width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; background: #fee2e2; color: #dc2626; border: none; border-radius: 0.5rem; cursor: pointer; @@ -74,7 +74,7 @@ {% endif %} {% if explanation.staff %} -
+

{% trans "Investigating as" %}

{{ explanation.staff.first_name }} {{ explanation.staff.last_name }}

{% if explanation.staff.department %}

{{ explanation.staff.department.name }}

{% endif %} @@ -95,8 +95,8 @@ {% if accused_staff %}
{% for s in accused_staff %} -