Merge feat/feedback-workflow-realignment: realign inquiry/observation/suggestion/appreciation workflows
All checks were successful
Build and Push Docker Image / build (push) Successful in 4m52s

Realigns the five patient-feedback workflows to the client's vision:
- Inquiry: department owns resolution end-to-end (removed PX-side respond UI,
  dept_manager now in response permission, contact_status hint)
- Observation: dept_manager can respond; Action/RCA/QI decision tree in
  post-response banner; Inform Reporter step on resolved
- Suggestion: optional implemented-notice SMS on close
- Appreciation: collapsed to 3 states (DRAFT/ACTIVATED/SENT), migration 0010
- Complaint: enriched Resolve modal (resolution_category/outcome) + view fix
- Department dashboard: Appreciations Received card
- Dead code removal; root conftest.py for template-rendering tests

13 commits, 23 files, +674/-527. 55 tests pass, 0 new failures vs baseline.
Whole-branch review passed (opus); Important + key Minor findings fixed.
This commit is contained in:
ismail 2026-07-21 06:55:46 +03:00
commit 9ea74e47ef
23 changed files with 673 additions and 526 deletions

View File

@ -1,308 +0,0 @@
"""
Appreciation forms
"""
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import (
Appreciation,
AppreciationAttachment,
AppreciationCategory,
AppreciationComment,
AppreciationStatus,
AppreciationType,
)
class AppreciationCategoryForm(forms.ModelForm):
"""Form for AppreciationCategory"""
class Meta:
model = AppreciationCategory
fields = ['name', 'description', 'is_active', 'display_order']
widgets = {
'name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Category name')
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Description')
}),
'is_active': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'display_order': forms.NumberInput(attrs={
'class': 'form-control',
'min': 0
}),
}
class AppreciationForm(forms.ModelForm):
"""Form for Appreciation"""
submitter_role = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Your role, e.g., Nurse Manager')
})
)
class Meta:
model = Appreciation
fields = [
'title',
'description',
'story',
'appreciation_type',
'category',
'recipient_name',
'recipient_type',
'recipient_id',
'submitter_role',
'tags',
'is_public',
'share_on_dashboard',
'share_in_newsletter',
]
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Appreciation title')
}),
'description': forms.Textarea(attrs={
'class': 'form-control',
'rows': 5,
'placeholder': _('Describe the appreciation')
}),
'story': forms.Textarea(attrs={
'class': 'form-control',
'rows': 8,
'placeholder': _('Tell the story behind this appreciation')
}),
'appreciation_type': forms.Select(attrs={
'class': 'form-select'
}),
'category': forms.Select(attrs={
'class': 'form-select'
}),
'recipient_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Name of person or team')
}),
'recipient_type': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('e.g., Staff, Patient, Department')
}),
'recipient_id': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('System ID if applicable')
}),
'tags': forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': _('Comma-separated tags')
}),
'is_public': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'share_on_dashboard': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
'share_in_newsletter': forms.CheckboxInput(attrs={
'class': 'form-check-input'
}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
hospital = kwargs.pop('hospital', None)
super().__init__(*args, **kwargs)
# Filter categories to active ones only
if self.fields.get('category'):
self.fields['category'].queryset = AppreciationCategory.objects.filter(
is_active=True
).order_by('display_order', 'name')
# Set default values based on context
if user and not self.instance.pk:
self.instance.submitted_by = user
if hospital and not self.instance.pk:
self.instance.hospital = hospital
def clean_tags(self):
tags = self.cleaned_data.get('tags', '')
if tags:
# Convert comma-separated string to list
return [tag.strip() for tag in tags.split(',') if tag.strip()]
return []
class AppreciationSubmitForm(AppreciationForm):
"""Form for submitting an appreciation (sets status to submitted)"""
def save(self, commit=True):
instance = super().save(commit=False)
instance.status = AppreciationStatus.SUBMITTED
if commit:
instance.save()
return instance
class AppreciationAcknowledgeForm(forms.ModelForm):
"""Form for acknowledging an appreciation"""
acknowledgment_notes = forms.CharField(
required=True,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': _('Add your acknowledgment notes here')
})
)
class Meta:
model = Appreciation
fields = ['acknowledgment_notes']
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
if user and not self.instance.pk:
self.instance.acknowledged_by = user
class AppreciationAttachmentForm(forms.ModelForm):
"""Form for uploading appreciation attachments"""
description = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 2,
'placeholder': _('Describe the attachment')
})
)
class Meta:
model = AppreciationAttachment
fields = ['file', 'description']
widgets = {
'file': forms.FileInput(attrs={
'class': 'form-control',
'accept': 'image/*,.pdf,.doc,.docx'
}),
}
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
super().__init__(*args, **kwargs)
if user and not self.instance.pk:
self.instance.uploaded_by = user
def save(self, commit=True):
instance = super().save(commit=False)
# Extract file information
if instance.file:
instance.filename = instance.file.name
instance.file_type = instance.file.content_type
instance.file_size = instance.file.size
if commit:
instance.save()
return instance
class AppreciationCommentForm(forms.ModelForm):
"""Form for adding comments to appreciations"""
comment = forms.CharField(
required=True,
widget=forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': _('Write your comment here')
})
)
is_internal = forms.BooleanField(
required=False,
label=_('Internal Comment'),
help_text=_('Check if this is an internal comment only visible to staff'),
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input'
})
)
class Meta:
model = AppreciationComment
fields = ['comment', 'is_internal']
def __init__(self, *args, **kwargs):
user = kwargs.pop('user', None)
appreciation = kwargs.pop('appreciation', None)
super().__init__(*args, **kwargs)
if user and not self.instance.pk:
self.instance.user = user
if appreciation and not self.instance.pk:
self.instance.appreciation = appreciation
class AppreciationFilterForm(forms.Form):
"""Form for filtering appreciations"""
search = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'form-control',
'placeholder': _('Search appreciations...')
})
)
status = forms.ChoiceField(
required=False,
choices=[('', _('All Statuses'))] + AppreciationStatus.choices,
widget=forms.Select(attrs={
'class': 'form-select'
})
)
appreciation_type = forms.ChoiceField(
required=False,
choices=[('', _('All Types'))] + AppreciationType.choices,
widget=forms.Select(attrs={
'class': 'form-select'
})
)
category = forms.ModelChoiceField(
required=False,
queryset=AppreciationCategory.objects.filter(
is_active=True
).order_by('display_order', 'name'),
empty_label=_('All Categories'),
widget=forms.Select(attrs={
'class': 'form-select'
})
)
date_from = forms.DateField(
required=False,
widget=forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
})
)
date_to = forms.DateField(
required=False,
widget=forms.DateInput(attrs={
'class': 'form-control',
'type': 'date'
})
)
is_public = forms.BooleanField(
required=False,
widget=forms.CheckboxInput(attrs={
'class': 'form-check-input'
})
)

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-07-20 19:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('appreciation', '0009_add_national_id'),
]
operations = [
migrations.AlterField(
model_name='appreciation',
name='status',
field=models.CharField(choices=[('draft', 'Draft'), ('activated', 'Activated'), ('sent', 'Sent')], db_index=True, default='draft', max_length=20),
),
]

View File

@ -17,21 +17,24 @@ from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
class AppreciationStatus(models.TextChoices): class AppreciationStatus(models.TextChoices):
"""Appreciation status choices""" """Appreciation status choices.
Collapsed to a 3-state lifecycle: an appreciation is drafted, PX activates
it, and sending it to the department/manager is terminal. AI analysis still
runs on activation but is stored in ``ai_analysis`` JSON rather than
surfaced as a separate status. There is no acknowledge step the recipient
is notified on send, and recognition (badges, leaderboard) fires at SENT.
"""
DRAFT = "draft", "Draft" DRAFT = "draft", "Draft"
ACTIVATED = "activated", "Activated" ACTIVATED = "activated", "Activated"
AI_ANALYZED = "ai_analyzed", "AI Analyzed"
SENT = "sent", "Sent" SENT = "sent", "Sent"
ACKNOWLEDGED = "acknowledged", "Acknowledged"
VALID_APPRECIATION_TRANSITIONS = { VALID_APPRECIATION_TRANSITIONS = {
AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED}, AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED},
AppreciationStatus.ACTIVATED: {AppreciationStatus.AI_ANALYZED, AppreciationStatus.SENT}, AppreciationStatus.ACTIVATED: {AppreciationStatus.SENT},
AppreciationStatus.AI_ANALYZED: {AppreciationStatus.SENT}, AppreciationStatus.SENT: set(), # terminal
AppreciationStatus.SENT: {AppreciationStatus.ACKNOWLEDGED},
AppreciationStatus.ACKNOWLEDGED: set(),
} }
@ -350,17 +353,6 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
self.send_notification() self.send_notification()
def acknowledge(self):
"""Mark appreciation as acknowledged"""
from django.utils import timezone
if self.status != AppreciationStatus.SENT:
raise ValueError(f"Cannot acknowledge appreciation in '{self.status}' status. Must be in 'sent'.")
self.status = AppreciationStatus.ACKNOWLEDGED
self.acknowledged_at = timezone.now()
self.save(update_fields=["status", "acknowledged_at"])
def send_notification(self): def send_notification(self):
"""Send notification to recipient — handled by post_save signal.""" """Send notification to recipient — handled by post_save signal."""
pass pass

View File

@ -0,0 +1,45 @@
"""Tests for the appreciation 3-state lifecycle realignment."""
import pytest
from django.test import TestCase
from django.urls import reverse, NoReverseMatch
from apps.appreciation.models import AppreciationStatus, VALID_APPRECIATION_TRANSITIONS
class TestAppreciationThreeStateLifecycle(TestCase):
"""Appreciation has exactly 3 statuses: DRAFT, ACTIVATED, SENT."""
def test_only_three_statuses_exist(self):
choices = [value for value, _ in AppreciationStatus.choices]
assert set(choices) == {"draft", "activated", "sent"}, \
f"Expected exactly 3 statuses, got: {choices}"
def test_no_ai_analyzed_or_acknowledged(self):
assert not hasattr(AppreciationStatus, "AI_ANALYZED"), \
"AI_ANALYZED should be removed"
assert not hasattr(AppreciationStatus, "ACKNOWLEDGED"), \
"ACKNOWLEDGED should be removed"
def test_sent_is_terminal(self):
"""SENT has no outgoing transitions."""
assert VALID_APPRECIATION_TRANSITIONS.get(AppreciationStatus.SENT) == set(), \
"SENT should be terminal"
def test_activated_can_go_to_sent(self):
"""ACTIVATED transitions directly to SENT (no AI_ANALYZED intermediate)."""
assert AppreciationStatus.SENT in \
VALID_APPRECIATION_TRANSITIONS[AppreciationStatus.ACTIVATED]
class TestAppreciationRemovedUrls(TestCase):
"""The acknowledge and send-dept-reminder URL routes are gone."""
def test_acknowledge_url_does_not_resolve(self):
with pytest.raises(NoReverseMatch):
reverse("appreciation:appreciation_acknowledge",
kwargs={"pk": "00000000-0000-0000-0000-000000000000"})
def test_send_dept_reminder_url_does_not_resolve(self):
with pytest.raises(NoReverseMatch):
reverse("appreciation:appreciation_send_dept_reminder",
kwargs={"pk": "00000000-0000-0000-0000-000000000000"})

View File

@ -63,6 +63,11 @@ def appreciation_list(request):
queryset = queryset.none() queryset = queryset.none()
status_filter_val = request.GET.get("status", "") status_filter_val = request.GET.get("status", "")
# Guard: silently reset unknown/legacy status values (e.g. a bookmarked
# ?status=acknowledged from before the 3-state collapse) to "all" so the
# user doesn't see an unexplained empty list.
if status_filter_val and status_filter_val not in dict(AppreciationStatus.choices):
status_filter_val = ""
if status_filter_val: if status_filter_val:
queryset = queryset.filter(status=status_filter_val) queryset = queryset.filter(status=status_filter_val)
@ -100,7 +105,7 @@ def appreciation_list(request):
"activated": base_qs.filter( "activated": base_qs.filter(
status=AppreciationStatus.ACTIVATED status=AppreciationStatus.ACTIVATED
).count(), ).count(),
"sent": base_qs.filter(status__in=[AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED]).count(), "sent": base_qs.filter(status=AppreciationStatus.SENT).count(),
} }
paginator = Paginator(queryset, 25) paginator = Paginator(queryset, 25)
@ -598,27 +603,18 @@ def appreciation_send_to(request, pk):
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def appreciation_acknowledge(request, pk): def appreciation_acknowledge(request, pk):
"""Acknowledge appreciation""" """DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
appreciation = get_object_or_404(Appreciation, pk=pk)
# Check if user is recipient The URL route was removed too. This view function is kept only so that
user_content_type = ContentType.objects.get_for_model(request.user) any stale import or bookmarked URL that somehow resolves does not crash
if not ( the worker; it redirects with a clear message. Safe to delete once no
appreciation.recipient_content_type == user_content_type and references remain.
appreciation.recipient_object_id == request.user.id """
): messages.error(
messages.error(request, "You can only acknowledge appreciations sent to you.") request,
return redirect('appreciation:appreciation_detail', pk=pk) _("Appreciation acknowledge is no longer supported — sending is the final step."),
)
if appreciation.status != AppreciationStatus.SENT: return redirect("appreciation:appreciation_detail", pk=pk)
messages.error(request, "This appreciation cannot be acknowledged in its current status.")
return redirect('appreciation:appreciation_detail', pk=pk)
# Acknowledge
appreciation.acknowledge()
messages.success(request, "Appreciation acknowledged successfully.")
return redirect('appreciation:appreciation_detail', pk=pk)
@login_required @login_required
@ -635,7 +631,7 @@ def appreciation_send_dept_reminder(request, pk):
messages.error(request, _("You don't have permission to send reminders.")) messages.error(request, _("You don't have permission to send reminders."))
return redirect("appreciation:appreciation_detail", pk=pk) return redirect("appreciation:appreciation_detail", pk=pk)
if appreciation.status not in (AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED): if appreciation.status != AppreciationStatus.SENT:
messages.warning(request, _("Appreciation must be sent before sending reminders.")) messages.warning(request, _("Appreciation must be sent before sending reminders."))
return redirect("appreciation:appreciation_detail", pk=pk) return redirect("appreciation:appreciation_detail", pk=pk)

View File

@ -37,8 +37,6 @@ urlpatterns = [
path('detail/<uuid:pk>/select-recipient/', ui_views.appreciation_select_recipient, name='appreciation_select_recipient'), path('detail/<uuid:pk>/select-recipient/', ui_views.appreciation_select_recipient, name='appreciation_select_recipient'),
path('detail/<uuid:pk>/send/', ui_views.appreciation_send, name='appreciation_send'), path('detail/<uuid:pk>/send/', ui_views.appreciation_send, name='appreciation_send'),
path('detail/<uuid:pk>/send-to/', ui_views.appreciation_send_to, name='appreciation_send_to'), path('detail/<uuid:pk>/send-to/', ui_views.appreciation_send_to, name='appreciation_send_to'),
path('detail/<uuid:pk>/send-reminder/', ui_views.appreciation_send_dept_reminder, name='appreciation_send_dept_reminder'),
path('acknowledge/<uuid:pk>/', ui_views.appreciation_acknowledge, name='appreciation_acknowledge'),
path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'), path('leaderboard/', ui_views.leaderboard_view, name='leaderboard_view'),
path('badges/', ui_views.my_badges_view, name='my_badges_view'), path('badges/', ui_views.my_badges_view, name='my_badges_view'),

View File

@ -211,26 +211,16 @@ class AppreciationViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
def acknowledge(self, request, pk=None): def acknowledge(self, request, pk=None):
"""Acknowledge an appreciation""" """DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
appreciation = self.get_object()
# Check if user is the recipient Kept as a 410 Gone endpoint so old API clients get a clear error
user_content_type = ContentType.objects.get_for_model(request.user) instead of a 404. Will be removed in a future release.
if not ( """
appreciation.recipient_content_type == user_content_type
and appreciation.recipient_object_id == request.user.id
):
return Response( return Response(
{"error": "You can only acknowledge appreciations sent to you"}, status=status.HTTP_403_FORBIDDEN {"error": "Appreciation acknowledge is no longer supported — SENT is terminal."},
status=status.HTTP_410_GONE,
) )
# Acknowledge
appreciation.acknowledge()
# Serialize and return
serializer = AppreciationSerializer(appreciation)
return Response(serializer.data)
@action(detail=False, methods=["get"]) @action(detail=False, methods=["get"])
def my_appreciations(self, request): def my_appreciations(self, request):
"""Get appreciations for the current user""" """Get appreciations for the current user"""

View File

@ -0,0 +1,148 @@
"""Tests for the feedback workflow realignment (Inquiry department-ownership)."""
from django.contrib.auth.models import Group
import pytest
from django.test import TestCase
from django.urls import reverse
from apps.accounts.models import User
from apps.organizations.models import Department, Hospital
from apps.complaints.models import Inquiry
def _ensure_group(name):
"""Get or create a role group (pytest runs with --nomigrations, so seeded
groups may not exist in the test DB mirrors the pattern in
tests_routing_rejection.py and tests_workflow_fixes.py)."""
grp, _ = Group.objects.get_or_create(name=name)
return grp
@pytest.mark.django_db
class TestInquiryDepartmentResponsePermission(TestCase):
"""A department manager of the handling department can record a response.
Uses self.client (full middleware incl. django.contrib.messages) rather
than calling the view directly with RequestFactory, because the view
calls messages.error() on the permission-denied path.
"""
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hospital", code="TH01")
self.dept = Department.objects.create(name="Dept A", hospital=self.hospital, code="DA")
self.other_dept = Department.objects.create(name="Dept B", hospital=self.hospital, code="DB")
_ensure_group("Department Manager")
self.dept_manager = User.objects.create_user(
username="dmgr", email="dmgr@test", password="x", department=self.dept
)
self.dept_manager.groups.add(Group.objects.get(name="Department Manager"))
self.other_manager = User.objects.create_user(
username="other_dmgr", email="o@test", password="x", department=self.other_dept
)
self.other_manager.groups.add(Group.objects.get(name="Department Manager"))
self.inquiry = Inquiry.objects.create(
hospital=self.hospital,
department=self.dept,
subject="Test",
message="msg",
status="in_progress",
sent_to_department=True,
)
def test_dept_manager_of_handling_dept_can_access_response_view(self):
"""GET to inquiry_department_response succeeds (200) for dept manager of handling dept."""
self.client.force_login(self.dept_manager)
response = self.client.get(
reverse("inquiries:inquiry_department_response", kwargs={"pk": self.inquiry.pk})
)
# 200 = permission granted, 302 = permission denied redirect.
assert response.status_code == 200, \
f"dept_manager of handling dept should be allowed, got {response.status_code}"
def test_dept_manager_of_other_dept_cannot_access(self):
"""Dept manager of a non-handling department is denied (302 redirect)."""
self.client.force_login(self.other_manager)
response = self.client.get(
reverse("inquiries:inquiry_department_response", kwargs={"pk": self.inquiry.pk})
)
assert response.status_code == 302, \
f"dept_manager of other dept should be redirected (denied), got {response.status_code}"
@pytest.mark.django_db
class TestInquiryDetailUIRealignment(TestCase):
"""The inquiry detail page no longer surfaces the PX-side 'Response to Patient'
action. Resolution happens via direct POST to inquiry_change_status.
Locks in Task 5 of the workflow realignment: department owns resolution.
"""
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hospital UI", code="TH03")
self.dept = Department.objects.create(name="Dept UI", hospital=self.hospital, code="UI")
_ensure_group("PX Admin")
self.px_admin = User.objects.create_user(
username="pxui", email="pxui@test", password="x", hospital=self.hospital
)
self.px_admin.groups.add(Group.objects.get(name="PX Admin"))
def _make_inquiry(self, **kwargs):
defaults = dict(
hospital=self.hospital,
department=self.dept,
subject="UI test",
message="msg",
status="in_progress",
activated_at=kwargs.pop("activated_at", __import__("django.utils.timezone", fromlist=["now"]).now()),
)
defaults.update(kwargs)
return Inquiry.objects.create(**defaults)
def test_response_to_patient_button_is_gone(self):
"""The 'Response to Patient' tile button must not appear anywhere on the page."""
inquiry = self._make_inquiry()
self.client.force_login(self.px_admin)
# PX admins are redirected to select-hospital unless the session has a
# selected_hospital_id (HospitalSelectionMiddleware). Set it directly.
session = self.client.session
session["selected_hospital_id"] = str(self.hospital.id)
session.save()
response = self.client.get(reverse("inquiries:inquiry_detail", kwargs={"pk": inquiry.pk}))
assert response.status_code == 200
# The PX-side respond modal and its trigger are removed. The button label
# may still appear inside {% comment %} blocks (inert), but the live
# onclick="showRespondModal()" button in the actions tile is gone.
content = response.content.decode()
# showRespondModal JS function definition must be gone.
assert "function showRespondModal" not in content, \
"showRespondModal JS function should be removed"
# The respondModal div must be gone.
assert 'id="respondModal"' not in content, \
"respondModal div should be removed"
def test_resolve_uses_direct_status_change_form(self):
"""The Resolve button POSTs directly to inquiry_change_status (not a modal)."""
from django.utils import timezone
# Department has responded → "Department has responded" banner shows Resolve.
inquiry = self._make_inquiry(
sent_to_department=True,
department_responded_at=timezone.now(),
)
self.client.force_login(self.px_admin)
session = self.client.session
session["selected_hospital_id"] = str(self.hospital.id)
session.save()
response = self.client.get(reverse("inquiries:inquiry_detail", kwargs={"pk": inquiry.pk}))
assert response.status_code == 200
content = response.content.decode()
change_status_url = reverse("inquiries:inquiry_change_status", kwargs={"pk": inquiry.pk})
# The Resolve button must be a real form posting to inquiry_change_status
# with status=resolved — not a showRespondModal() trigger.
assert f'action="{change_status_url}"' in content, \
"Resolve must POST to inquiry_change_status"
assert 'name="status" value="resolved"' in content, \
"Resolve form must include hidden status=resolved input"

View File

@ -1589,6 +1589,7 @@ def complaint_change_status(request, pk):
new_status = request.POST.get("status") new_status = request.POST.get("status")
note = request.POST.get("note", "") note = request.POST.get("note", "")
resolution = request.POST.get("resolution", "") resolution = request.POST.get("resolution", "")
resolution_category = request.POST.get("resolution_category", "")
resolution_outcome = request.POST.get("resolution_outcome", "") resolution_outcome = request.POST.get("resolution_outcome", "")
resolution_outcome_other = request.POST.get("resolution_outcome_other", "") resolution_outcome_other = request.POST.get("resolution_outcome_other", "")
@ -1600,6 +1601,7 @@ def complaint_change_status(request, pk):
request=request, request=request,
note=note, note=note,
resolution=resolution, resolution=resolution,
resolution_category=resolution_category,
resolution_outcome=resolution_outcome, resolution_outcome=resolution_outcome,
resolution_outcome_other=resolution_outcome_other, resolution_outcome_other=resolution_outcome_other,
) )
@ -4475,12 +4477,10 @@ def inquiry_department_response(request, pk):
if not ( if not (
user.is_px_admin() user.is_px_admin()
or user.is_hospital_admin() or user.is_hospital_admin()
or (user.is_champion() and (
inquiry.department == user.department
or inquiry.outgoing_department == user.department
))
or user.is_px_management() or user.is_px_management()
or user.is_px_employee() or user.is_px_employee()
or (user.is_champion() and user.department_id == inquiry.handling_department_id)
or (user.is_department_manager() and user.department_id == inquiry.handling_department_id)
): ):
messages.error(request, "You don't have permission to respond to this inquiry.") messages.error(request, "You don't have permission to respond to this inquiry.")
return redirect("inquiries:inquiry_detail", pk=pk) return redirect("inquiries:inquiry_detail", pk=pk)

View File

@ -0,0 +1,91 @@
"""Tests for the suggestion workflow realignment (optional implemented-notice SMS)."""
from unittest.mock import patch
from django.contrib.auth.models import Group
import pytest
from django.test import TestCase
from django.urls import reverse
from apps.accounts.models import User
from apps.organizations.models import Hospital
from apps.feedback.models import Feedback, FeedbackStatus, FeedbackType
def _ensure_group(name):
"""Get or create a role group (pytest runs with --nomigrations)."""
grp, _ = Group.objects.get_or_create(name=name)
return grp
@pytest.mark.django_db
class TestSuggestionImplementedNotice(TestCase):
"""Closing a suggestion with implemented_notice=True sends the high-value SMS.
Uses self.client because feedback_change_status calls messages.success/error
(needs MessageMiddleware) and redirects (302) no template rendering.
"""
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hospital", code="TH09")
# Use PX Employee (not PX Admin) so HospitalSelectionMiddleware uses
# user.hospital directly instead of requiring session['selected_hospital_id'].
_ensure_group("PX Employee")
self.user = User.objects.create_user(
username="pxsug", email="pxsug@test", password="x", hospital=self.hospital
)
self.user.groups.add(Group.objects.get(name="PX Employee"))
self.client.force_login(self.user)
self.feedback = Feedback.objects.create(
hospital=self.hospital,
feedback_type=FeedbackType.SUGGESTION,
status=FeedbackStatus.REVIEWED,
contact_phone="+966500000000",
contact_name="Patient",
message="Add online check-in",
)
@patch("apps.feedback.views.NotificationService.send_sms")
def test_close_with_implemented_notice_sends_implemented_sms(self, mock_sms):
"""When implemented_notice=on and status=closed, SMS body mentions 'implemented'."""
response = self.client.post(
reverse("feedback:feedback_change_status", kwargs={"pk": self.feedback.pk}),
data={"status": "closed", "implemented_notice": "on"},
)
assert response.status_code == 302, f"expected redirect, got {response.status_code}"
self.feedback.refresh_from_db()
assert self.feedback.status == FeedbackStatus.CLOSED
assert mock_sms.called, "send_sms should have been called"
last_call_body = mock_sms.call_args[0][1]
assert "implemented" in last_call_body.lower(), \
f"Expected 'implemented' in SMS body, got: {last_call_body}"
@patch("apps.feedback.views.NotificationService.send_sms")
def test_close_without_implemented_notice_sends_generic_sms(self, mock_sms):
"""Without the flag, the generic closed message fires (no 'implemented')."""
response = self.client.post(
reverse("feedback:feedback_change_status", kwargs={"pk": self.feedback.pk}),
data={"status": "closed"},
)
assert response.status_code == 302
self.feedback.refresh_from_db()
assert self.feedback.status == FeedbackStatus.CLOSED
assert mock_sms.called
last_call_body = mock_sms.call_args[0][1]
assert "implemented" not in last_call_body.lower(), \
f"Did not expect 'implemented' in generic SMS, got: {last_call_body}"
@patch("apps.feedback.views.NotificationService.send_sms")
def test_implemented_notice_ignored_when_status_not_closed(self, mock_sms):
"""The flag has no effect when transitioning to a non-closed status (e.g. acknowledged)."""
response = self.client.post(
reverse("feedback:feedback_change_status", kwargs={"pk": self.feedback.pk}),
data={"status": "acknowledged", "implemented_notice": "on"},
)
assert response.status_code == 302
self.feedback.refresh_from_db()
assert self.feedback.status == FeedbackStatus.ACKNOWLEDGED
# SMS fires for acknowledged too (existing behavior), but must NOT say 'implemented'.
if mock_sms.called:
last_call_body = mock_sms.call_args[0][1]
assert "implemented" not in last_call_body.lower()

View File

@ -913,13 +913,30 @@ def feedback_change_status(request, pk):
# Notify the suggester on acknowledge/close (closed-loop communication). # Notify the suggester on acknowledge/close (closed-loop communication).
# Only for suggestions with a patient-facing contact channel; internal / # Only for suggestions with a patient-facing contact channel; internal /
# source-user submissions have no patient to notify. # source-user submissions have no patient to notify.
implemented_notice = (
request.POST.get("implemented_notice") in ("on", "true", "True", "1")
and new_status == FeedbackStatus.CLOSED
)
if new_status in (FeedbackStatus.ACKNOWLEDGED, FeedbackStatus.CLOSED) and feedback.contact_phone: if new_status in (FeedbackStatus.ACKNOWLEDGED, FeedbackStatus.CLOSED) and feedback.contact_phone:
try: try:
label = "acknowledged" if new_status == FeedbackStatus.ACKNOWLEDGED else "closed" if implemented_notice:
body = (
f"Good news — your suggestion (ref {feedback.reference_number}) "
f"has been implemented. Thank you for helping us improve."
)
elif new_status == FeedbackStatus.ACKNOWLEDGED:
body = (
f"Your suggestion (ref {feedback.reference_number}) has been acknowledged. "
f"Thank you for helping us improve."
)
else: # CLOSED without implemented flag
body = (
f"Your suggestion (ref {feedback.reference_number}) has been closed. "
f"Thank you for helping us improve."
)
NotificationService.send_sms( NotificationService.send_sms(
feedback.contact_phone, feedback.contact_phone,
f"Your suggestion (ref {feedback.reference_number}) has been {label}. " body,
f"Thank you for helping us improve.",
related_object=feedback, related_object=feedback,
) )
except Exception: except Exception:

View File

@ -0,0 +1,109 @@
"""Tests for the observation workflow realignment."""
from django.contrib.auth.models import Group
import pytest
from django.test import TestCase
from django.urls import reverse
from apps.accounts.models import User
from apps.organizations.models import Department, Hospital
from apps.observations.models import Observation
def _ensure_group(name):
"""Get or create a role group (pytest runs with --nomigrations, so seeded
groups may not exist in the test DB)."""
grp, _ = Group.objects.get_or_create(name=name)
return grp
@pytest.mark.django_db
class TestObservationDepartmentResponsePermission(TestCase):
"""A department manager of the assigned department can respond.
Uses self.client (full middleware incl. django.contrib.messages) the
view calls messages.error() on the permission-denied path. The project's
root conftest.py disables ManifestStaticFilesStorage for all tests.
"""
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hospital", code="TH02")
self.dept = Department.objects.create(name="Dept A", hospital=self.hospital, code="OA")
self.other_dept = Department.objects.create(name="Dept B", hospital=self.hospital, code="OB")
_ensure_group("Department Manager")
self.dept_manager = User.objects.create_user(
username="odmgr", email="odmgr@test", password="x", department=self.dept
)
self.dept_manager.groups.add(Group.objects.get(name="Department Manager"))
self.other_manager = User.objects.create_user(
username="oother", email="oo@test", password="x", department=self.other_dept
)
self.other_manager.groups.add(Group.objects.get(name="Department Manager"))
self.observation = Observation.objects.create(
description="Test observation description here",
hospital=self.hospital,
assigned_department=self.dept,
sent_to_department=True,
status="in_progress",
)
def test_dept_manager_of_assigned_dept_can_access_response_view(self):
"""GET to observation_department_response succeeds (200) for dept manager of assigned dept."""
self.client.force_login(self.dept_manager)
response = self.client.get(
reverse("observations:observation_department_response", kwargs={"pk": self.observation.pk})
)
assert response.status_code == 200, \
f"dept_manager of assigned dept should be allowed, got {response.status_code}"
def test_dept_manager_of_other_dept_cannot_access(self):
"""Dept manager of a non-assigned department is denied (302 redirect)."""
self.client.force_login(self.other_manager)
response = self.client.get(
reverse("observations:observation_department_response", kwargs={"pk": self.observation.pk})
)
assert response.status_code == 302, \
f"dept_manager of other dept should be redirected (denied), got {response.status_code}"
@pytest.mark.django_db
class TestObservationDetailRespondButtonGate(TestCase):
"""The 'Submit Response' button must be visible to dept_manager of the
assigned department not just pass the view's permission check.
Locks in the fix for the Important issue found in whole-branch review:
Task 2 added dept_manager to the view predicate but the template context
var `can_respond_to_department` was missed, so the button stayed hidden.
"""
def setUp(self):
self.hospital = Hospital.objects.create(name="Test Hospital Gate", code="TH04")
self.dept = Department.objects.create(name="Dept Gate", hospital=self.hospital, code="GT")
_ensure_group("Department Manager")
self.dept_manager = User.objects.create_user(
username="gdmgr", email="gdmgr@test", password="x", department=self.dept
)
self.dept_manager.groups.add(Group.objects.get(name="Department Manager"))
self.observation = Observation.objects.create(
description="Gate test observation",
hospital=self.hospital,
assigned_department=self.dept,
sent_to_department=True,
department_responded_at=None,
status="in_progress",
)
def test_dept_manager_sees_respond_button(self):
"""The department detail page renders the respond control for dept managers."""
self.client.force_login(self.dept_manager)
response = self.client.get(
reverse("observations:observation_detail", kwargs={"pk": self.observation.pk})
)
assert response.status_code == 200
assert response.context["can_respond_to_department"] is True, \
"dept_manager of assigned dept must see can_respond_to_department=True"

View File

@ -653,7 +653,7 @@ def observation_detail(request, pk):
"can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee(), "can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee(),
"can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), "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_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_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) or (user.is_department_manager() and observation.assigned_department == user.department),
"can_send_reminder": 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_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() or user.is_px_employee(), "can_admin": user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee(),
@ -1628,6 +1628,7 @@ def observation_department_response(request, pk):
or user.is_px_management() or user.is_px_management()
or user.is_px_employee() or user.is_px_employee()
or (user.is_champion() and observation.assigned_department == user.department) or (user.is_champion() and observation.assigned_department == user.department)
or (user.is_department_manager() and observation.assigned_department == user.department)
): ):
messages.error(request, "You don't have permission to respond to this observation.") messages.error(request, "You don't have permission to respond to this observation.")
return redirect("observations:observation_detail", pk=pk) return redirect("observations:observation_detail", pk=pk)

View File

@ -2326,9 +2326,34 @@ def department_detail(request, pk):
.filter(Q(champion__isnull=False) | Q(manager__isnull=False)) .filter(Q(champion__isnull=False) | Q(manager__isnull=False))
.order_by("name") .order_by("name")
), ),
# Appreciations received by this department (SENT = terminal/recognized).
# Surfaced so managers see recognition alongside other feedback.
"dept_appreciations": _dept_appreciations_qs(department),
"dept_appreciations_count": _dept_appreciations_count(department),
} }
return render(request, "organizations/department_detail.html", context) return render(request, "organizations/department_detail.html", context)
def _dept_appreciations_qs(department):
"""Recent SENT appreciations for the department detail dashboard."""
from apps.appreciation.models import Appreciation, AppreciationStatus
return (
Appreciation.objects.filter(
department=department,
status=AppreciationStatus.SENT,
).select_related("sender", "category").order_by("-sent_at")[:10]
)
def _dept_appreciations_count(department):
"""Total SENT appreciations for the department detail dashboard."""
from apps.appreciation.models import Appreciation, AppreciationStatus
return Appreciation.objects.filter(
department=department,
status=AppreciationStatus.SENT,
).count()
def _check_department_access(user, department): def _check_department_access(user, department):
if user.is_px_admin(): if user.is_px_admin():
return True return True

30
conftest.py Normal file
View File

@ -0,0 +1,30 @@
"""Project-wide pytest fixtures.
The default test environment runs without a collected staticfiles manifest
(``addopts = "--reuse-db --nomigrations"`` in pyproject.toml), so any view
that renders a template containing ``{% static %}`` tags against
``CompressedManifestStaticFilesStorage`` raises ``ValueError: Missing
staticfiles manifest entry for ...``.
This autouse fixture swaps in plain ``StaticFilesStorage`` for the duration
of every test, so tests can assert ``status_code == 200`` on template-rendering
views without first running ``collectstatic``.
"""
import pytest
from django.test import override_settings
from config.settings.base import STORAGES as BASE_STORAGES
@pytest.fixture(autouse=True)
def _disable_static_manifest():
"""Use plain StaticFilesStorage (no manifest hashing) during tests."""
staticfiles_storage = {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
}
storages_override = {**BASE_STORAGES, "staticfiles": staticfiles_storage}
with override_settings(
STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage",
STORAGES=storages_override,
):
yield

View File

@ -20,9 +20,7 @@
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase <span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if appreciation.status == 'draft' %}bg-amber-100 text-amber-700 {% if appreciation.status == 'draft' %}bg-amber-100 text-amber-700
{% elif appreciation.status == 'activated' %}bg-blue-100 text-blue-700 {% elif appreciation.status == 'activated' %}bg-blue-100 text-blue-700
{% elif appreciation.status == 'ai_analyzed' %}bg-purple-100 text-purple-700
{% elif appreciation.status == 'sent' %}bg-green-100 text-green-700 {% elif appreciation.status == 'sent' %}bg-green-100 text-green-700
{% elif appreciation.status == 'acknowledged' %}bg-emerald-100 text-emerald-700
{% else %}bg-slate-100 text-slate-600{% endif %}"> {% else %}bg-slate-100 text-slate-600{% endif %}">
{{ appreciation.get_status_display }} {{ appreciation.get_status_display }}
</span> </span>
@ -130,8 +128,8 @@
<div class="bg-green-50 rounded-2xl border border-green-200 p-4 mb-6 flex items-center gap-3"> <div class="bg-green-50 rounded-2xl border border-green-200 p-4 mb-6 flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div> <div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
<div> <div>
<p class="text-sm font-bold text-navy">{% trans "Appreciation acknowledged" %}</p> <p class="text-sm font-bold text-navy">{% trans "Appreciation sent" %}</p>
<p class="text-xs text-slate">{% trans "Sent on" %} {{ appreciation.sent_at|date:"Y-m-d H:i" }} &middot; {% trans "Acknowledged" %} {{ appreciation.acknowledged_at|date:"Y-m-d H:i" }}</p> <p class="text-xs text-slate">{% trans "Sent on" %} {{ appreciation.sent_at|date:"Y-m-d H:i" }}</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@ -171,9 +171,7 @@
<option value="">{% trans "All Status" %}</option> <option value="">{% trans "All Status" %}</option>
<option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>{% trans "Draft" %}</option> <option value="draft" {% if status_filter == 'draft' %}selected{% endif %}>{% trans "Draft" %}</option>
<option value="activated" {% if status_filter == 'activated' %}selected{% endif %}>{% trans "Activated" %}</option> <option value="activated" {% if status_filter == 'activated' %}selected{% endif %}>{% trans "Activated" %}</option>
<option value="ai_analyzed" {% if status_filter == 'ai_analyzed' %}selected{% endif %}>{% trans "AI Analyzed" %}</option>
<option value="sent" {% if status_filter == 'sent' %}selected{% endif %}>{% trans "Sent" %}</option> <option value="sent" {% if status_filter == 'sent' %}selected{% endif %}>{% trans "Sent" %}</option>
<option value="acknowledged" {% if status_filter == 'acknowledged' %}selected{% endif %}>{% trans "Acknowledged" %}</option>
</select> </select>
</div> </div>
<button type="submit" class="px-5 py-2.5 bg-navy text-white rounded-xl text-sm font-bold hover:bg-blue transition flex items-center gap-2"> <button type="submit" class="px-5 py-2.5 bg-navy text-white rounded-xl text-sm font-bold hover:bg-blue transition flex items-center gap-2">
@ -243,9 +241,7 @@
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase <span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
{% if apr.status == 'draft' %}bg-amber-100 text-amber-700 {% if apr.status == 'draft' %}bg-amber-100 text-amber-700
{% elif apr.status == 'activated' %}bg-blue-100 text-blue-700 {% elif apr.status == 'activated' %}bg-blue-100 text-blue-700
{% elif apr.status == 'ai_analyzed' %}bg-purple-100 text-purple-700
{% elif apr.status == 'sent' %}bg-green-100 text-green-700 {% elif apr.status == 'sent' %}bg-green-100 text-green-700
{% elif apr.status == 'acknowledged' %}bg-emerald-100 text-emerald-700
{% else %}bg-slate-100 text-slate-700{% endif %}"> {% else %}bg-slate-100 text-slate-700{% endif %}">
{{ apr.get_status_display }} {{ apr.get_status_display }}
</span> </span>

View File

@ -961,6 +961,28 @@
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %} <span class="text-red-500">*</span></label> <label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Notes" %} <span class="text-red-500">*</span></label>
<textarea name="resolution" rows="4" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}" required></textarea> <textarea name="resolution" rows="4" class="w-full border border-slate-200 rounded-xl p-4 text-sm focus:ring-2 focus:ring-navy/20 outline-none" placeholder="{% trans 'Enter resolution details...' %}" required></textarea>
</div> </div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Category" %}</label>
<select name="resolution_category" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none bg-white">
<option value="">{% trans "— Select —" %}</option>
<option value="full_action_taken">{% trans "Full action taken" %}</option>
<option value="partial_action_taken">{% trans "Partial action taken" %}</option>
<option value="no_action_needed">{% trans "No action needed" %}</option>
<option value="cannot_resolve">{% trans "Cannot resolve" %}</option>
<option value="patient_withdrawn">{% trans "Patient withdrawn" %}</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate mb-2">{% trans "Resolution Outcome" %}</label>
<select name="resolution_outcome" class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none bg-white">
<option value="">{% trans "— Select —" %}</option>
<option value="patient">{% trans "In favour of patient" %}</option>
<option value="hospital">{% trans "In favour of hospital" %}</option>
<option value="other">{% trans "Other" %}</option>
</select>
</div>
</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button type="button" onclick="closeModal('resolveModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition"> <button type="button" onclick="closeModal('resolveModal')" class="flex-1 px-4 py-2 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">
{% trans "Cancel" %} {% trans "Cancel" %}

View File

@ -213,7 +213,11 @@
<button type="button" onclick="showRespondModal()" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">{% trans "Resolve" %}</button> <button type="button" onclick="showRespondModal()" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">{% trans "Resolve" %}</button>
</form> {% endcomment %} </form> {% endcomment %}
{% if can_respond and inquiry.activated_at %} {% if can_respond and inquiry.activated_at %}
<button type="button" onclick="showRespondModal()" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">{% trans "Resolve" %}</button> <form method="post" action="{% url 'inquiries:inquiry_change_status' inquiry.pk %}" class="inline shrink-0"
onsubmit="return confirm('{% trans "Mark this inquiry as resolved?" %}')">
{% csrf_token %}<input type="hidden" name="status" value="resolved">
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">{% trans "Resolve" %}</button>
</form>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@ -623,10 +627,6 @@
<i data-lucide="send" class="w-5 h-5 text-indigo-600"></i> <i data-lucide="send" class="w-5 h-5 text-indigo-600"></i>
<span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Department" %}</span> <span class="text-[10px] font-bold text-indigo-700 uppercase">{% trans "Send to Department" %}</span>
</button> </button>
<button onclick="showRespondModal()" class="col-span-2 p-3 border-navy bg-navy text-white rounded-xl hover:bg-blue transition flex items-center justify-center gap-2 group">
<i data-lucide="message-square" class="w-5 h-5"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Response to Patient" %}</span>
</button>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -702,84 +702,7 @@
</div> </div>
{% endif %} {% endif %}
<!-- Respond Modal --> <!-- Respond Modal (removed per workflow realignment: department owns resolution) -->
<div id="respondModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-slate-200">
<div class="flex items-center justify-between">
<h3 class="text-xl font-bold text-navy flex items-center gap-2">
<i data-lucide="message-square" class="w-5 h-5"></i>
{% trans "Response to Patient" %}
</h3>
<button type="button" onclick="closeModal('respondModal')" class="text-slate-400 hover:text-slate-600 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
</div>
<form method="post" action="{% url 'inquiries:inquiry_respond' inquiry.pk %}" id="respondForm">
{% csrf_token %}
<div class="p-6">
{% if inquiry.short_description_en or inquiry.short_description_ar or inquiry.ai_brief_en or inquiry.ai_brief_ar %}
<div class="bg-light/50 border border-slate-200 rounded-2xl p-4 mb-5">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="sparkles" class="w-4 h-4 text-navy"></i>
<span class="text-sm font-bold text-navy">{% trans "AI Summary" %}</span>
</div>
{% if inquiry.short_description_en or inquiry.ai_brief_en %}
<p class="text-sm text-slate-700 leading-relaxed">{% if inquiry.short_description_en %}{{ inquiry.short_description_en }}{% elif inquiry.ai_brief_en %}{{ inquiry.ai_brief_en }}{% endif %}</p>
{% endif %}
{% if inquiry.short_description_ar or inquiry.ai_brief_ar %}
<p class="text-sm text-slate-700 leading-relaxed mt-2" dir="rtl">{% if inquiry.short_description_ar %}{{ inquiry.short_description_ar }}{% elif inquiry.ai_brief_ar %}{{ inquiry.ai_brief_ar }}{% endif %}</p>
{% endif %}
</div>
{% endif %}
<div class="mb-5">
<button type="button" id="generateAiBtn" onclick="generateAIResponse()" class="w-full inline-flex items-center justify-center gap-2 px-4 py-3 bg-navy text-white rounded-xl font-bold hover:bg-blue transition text-sm shadow-lg">
<i data-lucide="sparkles" class="w-4 h-4"></i>
{% trans "Generate AI Response" %}
</button>
</div>
<div id="aiSuggestions" class="hidden mb-5 space-y-3">
<div class="flex items-center gap-2 mb-2">
<i data-lucide="sparkles" class="w-4 h-4 text-navy"></i>
<span class="text-sm font-semibold text-navy">{% trans "AI Generated Response (click to use)" %}</span>
</div>
<div id="aiSuggestionEn" onclick="useAISuggestion('en')" class="ai-suggestion-card">
<p class="text-[10px] font-bold text-slate uppercase mb-1">English</p>
<p class="text-sm text-slate-700" id="aiSuggestionEnText"></p>
</div>
<div id="aiSuggestionAr" onclick="useAISuggestion('ar')" class="ai-suggestion-card" dir="rtl" style="text-align: right;">
<p class="text-[10px] font-bold text-slate uppercase mb-1">العربية</p>
<p class="text-sm text-slate-700" id="aiSuggestionArText"></p>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-navy mb-2">{% trans "Your Response" %} <span class="text-red-500">*</span></label>
<textarea name="response" id="responseText" rows="8"
class="w-full px-4 py-3 border-2 border-slate-200 rounded-xl focus:outline-none focus:border-navy focus:ring-2 focus:ring-navy/20 resize-none text-sm"
placeholder="{% trans 'Enter your response...' %}" required>{{ inquiry.response|default:'' }}</textarea>
</div>
<p class="text-xs text-slate-400 mt-1">
<i data-lucide="info" class="w-3 h-3 inline mr-1"></i>
{% trans "The response will be sent to the inquirer via SMS and Email." %}
</p>
</div>
<div class="p-6 border-t border-slate-200 flex gap-3">
<button type="submit" class="flex-1 px-4 py-2.5 bg-navy text-white rounded-xl font-semibold hover:bg-navy/90 transition text-sm inline-flex items-center justify-center gap-2">
<i data-lucide="send" class="w-4 h-4"></i>
{% trans "Response to Patient" %}
</button>
<button type="button" onclick="closeModal('respondModal')" class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl font-semibold text-slate-600 hover:bg-slate-50 transition text-sm">
{% trans "Cancel" %}
</button>
</div>
</form>
</div>
</div>
<!-- Assign Modal --> <!-- Assign Modal -->
<div id="assignModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div id="assignModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@ -989,10 +912,6 @@ function closeModal(id) {
document.getElementById(id).style.display = 'none'; document.getElementById(id).style.display = 'none';
} }
function showRespondModal() {
document.getElementById('respondModal').style.display = 'flex';
}
function showAssignModal() { function showAssignModal() {
document.getElementById('assignModal').style.display = 'flex'; document.getElementById('assignModal').style.display = 'flex';
} }
@ -1072,52 +991,6 @@ function loadDeptContactsInline(deptId) {
}); });
} }
function generateAIResponse() {
const btn = document.getElementById('generateAiBtn');
const suggestionsDiv = document.getElementById('aiSuggestions');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> {% trans "Generating..." %}';
suggestionsDiv.classList.add('hidden');
fetch('/complaints/api/inquiries/{{ inquiry.pk }}/generate_ai_response/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCSRFToken() },
credentials: 'same-origin'
})
.then(response => { if (!response.ok) return response.json().then(data => { throw new Error(data.error || 'Request failed'); }); return response.json(); })
.then(data => {
btn.disabled = false;
btn.innerHTML = '<i data-lucide="sparkles" class="w-4 h-4"></i> {% trans "Generate AI Response" %}';
lucide.createIcons();
if (data.success) {
document.getElementById('aiSuggestionEnText').textContent = data.response_en;
document.getElementById('aiSuggestionArText').textContent = data.response_ar;
suggestionsDiv.classList.remove('hidden');
} else { alert(data.error || '{% trans "Failed to generate response" %}'); }
})
.catch(error => {
console.error('Error:', error);
btn.disabled = false;
btn.innerHTML = '<i data-lucide="sparkles" class="w-4 h-4"></i> {% trans "Generate AI Response" %}';
lucide.createIcons();
alert(error.message || '{% trans "An error occurred while generating response" %}');
});
}
function useAISuggestion(lang) {
var text = '';
var card = null;
if (lang === 'en') {
text = document.getElementById('aiSuggestionEnText').textContent;
card = document.getElementById('aiSuggestionEn');
} else {
text = document.getElementById('aiSuggestionArText').textContent;
card = document.getElementById('aiSuggestionAr');
}
document.getElementById('responseText').value = text;
card.classList.add('selected');
setTimeout(() => card.classList.remove('selected'), 1500);
}
function reanalyzeAI() { function reanalyzeAI() {
const btn = document.getElementById('reanalyzeBtn'); const btn = document.getElementById('reanalyzeBtn');
const content = document.getElementById('aiAnalysisContent'); const content = document.getElementById('aiAnalysisContent');
@ -1163,7 +1036,6 @@ function escapeHtml(text) { const div = document.createElement('div'); div.textC
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeModal('respondModal');
closeModal('assignModal'); closeModal('assignModal');
closeModal('sendToDeptModal'); closeModal('sendToDeptModal');
} }

View File

@ -352,6 +352,10 @@
{% endfor %} {% endfor %}
</select> </select>
<textarea name="note" rows="2" placeholder="{% trans 'Add a note...' %}" class="w-full mt-2 px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-navy"></textarea> <textarea name="note" rows="2" placeholder="{% trans 'Add a note...' %}" class="w-full mt-2 px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-navy"></textarea>
<label class="flex items-center gap-2 mt-2 text-xs text-slate-600 cursor-pointer">
<input type="checkbox" name="implemented_notice" class="rounded border-slate-300 text-navy focus:ring-navy">
<span>{% trans "Implemented — notify the patient with the good news" %}</span>
</label>
<button type="submit" class="w-full mt-2 px-4 py-2 bg-navy text-white text-sm font-semibold rounded-lg hover:bg-navy/90 transition"> <button type="submit" class="w-full mt-2 px-4 py-2 bg-navy text-white text-sm font-semibold rounded-lg hover:bg-navy/90 transition">
<i data-lucide="refresh-cw" class="w-3 h-3 inline-block me-1"></i>{% trans "Update" %} <i data-lucide="refresh-cw" class="w-3 h-3 inline-block me-1"></i>{% trans "Update" %}
</button> </button>

View File

@ -124,22 +124,89 @@
{% endif %} {% endif %}
</div> </div>
{% elif observation.department_responded_at and observation.status not in 'resolved,closed' %} {% elif observation.department_responded_at and observation.status not in 'resolved,closed' %}
<div class="bg-green-50 rounded-2xl border border-green-200 p-4 mb-6 flex items-center justify-between gap-4"> <div class="bg-green-50 rounded-2xl border border-green-200 p-4 mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3 mb-3">
<div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div> <div class="w-9 h-9 rounded-full bg-green-100 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-green-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "Department has responded" %}</p><p class="text-xs text-slate">{% trans "Review the response and resolve this observation." %}</p></div> <div class="flex-1">
<p class="text-sm font-bold text-navy">{% trans "Department has responded" %}</p>
<p class="text-xs text-slate">{% trans "Decide what to do next, then resolve and inform the reporter." %}</p>
</div>
</div> </div>
{% if can_convert %} {% if can_convert %}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}" class="inline shrink-0" onsubmit="return confirm('{% trans "Mark this observation as resolved?" %}')"> <div class="grid grid-cols-2 md:grid-cols-4 gap-2">
{% if not observation.action_id %}
<a href="{% url 'observations:observation_convert_to_action' observation.id %}"
class="p-3 border-green-200 bg-white rounded-xl hover:bg-green-100 flex flex-col items-center gap-1 group transition text-center">
<i data-lucide="arrow-right-circle" class="w-5 h-5 text-green-600"></i>
<span class="text-[10px] font-bold text-green-700 uppercase leading-tight">{% trans "Create Action" %}</span>
<span class="text-[9px] text-slate">{% trans "Minor / isolated" %}</span>
</a>
{% else %}
<div class="p-3 border-green-200 bg-green-100 rounded-xl flex flex-col items-center gap-1 text-center">
<i data-lucide="check" class="w-5 h-5 text-green-600"></i>
<span class="text-[10px] font-bold text-green-700 uppercase leading-tight">{% trans "Action exists" %}</span>
</div>
{% endif %}
<a href="{% url 'rca:rca_create' %}?related_model=observation&related_id={{ observation.pk }}"
class="p-3 border-purple-200 bg-white rounded-xl hover:bg-purple-50 flex flex-col items-center gap-1 group transition text-center">
<i data-lucide="search" class="w-5 h-5 text-purple-600"></i>
<span class="text-[10px] font-bold text-purple-700 uppercase leading-tight">{% trans "Start RCA" %}</span>
<span class="text-[9px] text-slate">{% trans "Serious / pattern" %}</span>
</a>
{% if can_admin %}
<a href="{% url 'projects:project_create' %}?related_model=observation&related_id={{ observation.pk }}"
class="p-3 border-teal-200 bg-white rounded-xl hover:bg-teal-50 flex flex-col items-center gap-1 group transition text-center">
<i data-lucide="folder-plus" class="w-5 h-5 text-teal-600"></i>
<span class="text-[10px] font-bold text-teal-700 uppercase leading-tight">{% trans "QI Project" %}</span>
<span class="text-[9px] text-slate">{% trans "Systemic trend" %}</span>
</a>
{% endif %}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}"
class="contents" onsubmit="return confirm('{% trans "Mark this observation as resolved?" %}')">
{% csrf_token %}
<input type="hidden" name="status" value="resolved">
<button type="submit"
class="p-3 border-navy bg-navy text-white rounded-xl hover:bg-blue flex flex-col items-center gap-1 transition text-center">
<i data-lucide="check-circle" class="w-5 h-5"></i>
<span class="text-[10px] font-bold uppercase leading-tight">{% trans "Resolve" %}</span>
<span class="text-[9px] text-blue-200">{% trans "No action needed" %}</span>
</button>
</form>
</div>
{% else %}
{# Non-admin viewers see only the simple resolve form #}
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}"
class="inline" onsubmit="return confirm('{% trans "Mark this observation as resolved?" %}')">
{% csrf_token %}<input type="hidden" name="status" value="resolved"> {% csrf_token %}<input type="hidden" name="status" value="resolved">
<button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">{% trans "Resolve" %}</button> <button type="submit" class="px-4 py-2 bg-green-600 text-white rounded-lg font-bold text-sm hover:bg-green-700 transition whitespace-nowrap">
{% trans "Resolve" %}
</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% elif observation.status in 'resolved,closed' %} {% elif observation.status in 'resolved,closed' %}
<div class="bg-slate-50 rounded-2xl border border-slate-200 p-4 mb-6 flex items-center gap-3"> <div class="bg-slate-50 rounded-2xl border border-slate-200 p-4 mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-slate-200 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-slate-600"></i></div> <div class="w-9 h-9 rounded-full bg-slate-200 flex items-center justify-center shrink-0"><i data-lucide="check-circle-2" class="w-4 h-4 text-slate-600"></i></div>
<div><p class="text-sm font-bold text-navy">{% trans "This observation is resolved" %}</p></div> <div>
<p class="text-sm font-bold text-navy">{% trans "This observation is resolved" %}</p>
{% if observation.response_sent_at %}
<p class="text-xs text-slate">{% trans "Reporter informed on" %} {{ observation.response_sent_at|date:"M d, Y H:i" }}</p>
{% else %}
<p class="text-xs text-slate">{% trans "Reporter has not yet been informed." %}</p>
{% endif %}
</div>
</div>
{% if can_convert and not observation.response_sent_at and observation.status == 'resolved' %}
<button type="button" onclick="document.getElementById('respondModal').style.display='flex'"
class="px-4 py-2 bg-navy text-white rounded-lg font-bold text-sm hover:bg-blue transition whitespace-nowrap inline-flex items-center gap-2">
<i data-lucide="mail" class="w-4 h-4"></i>
{% trans "Inform Reporter" %}
</button>
{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -239,6 +239,35 @@
</div> </div>
{% endif %} {% endif %}
{% if dept_appreciations_count > 0 %}
<!-- Appreciations received by this department (recognition) -->
<div class="bg-gradient-to-br from-amber-50 to-yellow-50 border border-amber-200 rounded-2xl p-5 my-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center">
<i data-lucide="award" class="w-4 h-4 text-amber-600"></i>
</div>
<h3 class="text-sm font-bold text-navy">{% trans "Appreciations Received" %}</h3>
</div>
<span class="text-2xl font-bold text-amber-600">{{ dept_appreciations_count }}</span>
</div>
<ul class="space-y-2">
{% for apr in dept_appreciations %}
<li class="text-xs text-slate-700 flex items-start gap-2 bg-white/60 rounded-lg p-2">
<i data-lucide="quote" class="w-3 h-3 mt-0.5 shrink-0 text-amber-500"></i>
<span class="flex-1">
{{ apr.message_en|default:apr.message_ar|truncatewords:18 }}
<span class="block text-[10px] text-slate-400 mt-0.5">
{{ apr.sent_at|date:"M d, Y" }}
{% if apr.sender %}· {{ apr.sender.get_full_name }}{% endif %}
</span>
</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if can_respond and pending_routing_involvements %} {% if can_respond and pending_routing_involvements %}
<!-- Reject routing forms (for "Wrong Dept" buttons in the Pending Actions table) --> <!-- Reject routing forms (for "Wrong Dept" buttons in the Pending Actions table) -->
{% for inv in pending_routing_involvements %} {% for inv in pending_routing_involvements %}

View File

@ -60,6 +60,13 @@
</select> </select>
</form> </form>
{% if inquiry.status == 'in_progress' and inquiry.contact_status == 'not_contacted' %}
<span class="inline-flex items-center gap-1.5 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-2.5 py-1.5">
<i data-lucide="info" class="w-3.5 h-3.5"></i>
{% trans "Record a contact outcome above before resolving." %}
</span>
{% endif %}
{% if inquiry.status == 'in_progress' %} {% if inquiry.status == 'in_progress' %}
<!-- Mark Resolved (requires a patient contact outcome first) --> <!-- Mark Resolved (requires a patient contact outcome first) -->
<form method="post" action="{% url 'inquiries:inquiry_change_status' inquiry.pk %}" onsubmit="return confirm('{% filter escapejs %}{% trans "Mark this inquiry as resolved?" %}{% endfilter %}')"> <form method="post" action="{% url 'inquiries:inquiry_change_status' inquiry.pk %}" onsubmit="return confirm('{% filter escapejs %}{% trans "Mark this inquiry as resolved?" %}{% endfilter %}')">