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
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:
commit
9ea74e47ef
@ -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'
|
||||
})
|
||||
)
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -17,21 +17,24 @@ from apps.core.models import SoftDeleteModel, TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
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"
|
||||
ACTIVATED = "activated", "Activated"
|
||||
AI_ANALYZED = "ai_analyzed", "AI Analyzed"
|
||||
SENT = "sent", "Sent"
|
||||
ACKNOWLEDGED = "acknowledged", "Acknowledged"
|
||||
|
||||
|
||||
VALID_APPRECIATION_TRANSITIONS = {
|
||||
AppreciationStatus.DRAFT: {AppreciationStatus.ACTIVATED},
|
||||
AppreciationStatus.ACTIVATED: {AppreciationStatus.AI_ANALYZED, AppreciationStatus.SENT},
|
||||
AppreciationStatus.AI_ANALYZED: {AppreciationStatus.SENT},
|
||||
AppreciationStatus.SENT: {AppreciationStatus.ACKNOWLEDGED},
|
||||
AppreciationStatus.ACKNOWLEDGED: set(),
|
||||
AppreciationStatus.ACTIVATED: {AppreciationStatus.SENT},
|
||||
AppreciationStatus.SENT: set(), # terminal
|
||||
}
|
||||
|
||||
|
||||
@ -350,17 +353,6 @@ class Appreciation(UUIDModel, TimeStampedModel, SoftDeleteModel):
|
||||
|
||||
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):
|
||||
"""Send notification to recipient — handled by post_save signal."""
|
||||
pass
|
||||
|
||||
45
apps/appreciation/test_workflow_realignment.py
Normal file
45
apps/appreciation/test_workflow_realignment.py
Normal 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"})
|
||||
@ -63,6 +63,11 @@ def appreciation_list(request):
|
||||
queryset = queryset.none()
|
||||
|
||||
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:
|
||||
queryset = queryset.filter(status=status_filter_val)
|
||||
|
||||
@ -100,7 +105,7 @@ def appreciation_list(request):
|
||||
"activated": base_qs.filter(
|
||||
status=AppreciationStatus.ACTIVATED
|
||||
).count(),
|
||||
"sent": base_qs.filter(status__in=[AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED]).count(),
|
||||
"sent": base_qs.filter(status=AppreciationStatus.SENT).count(),
|
||||
}
|
||||
|
||||
paginator = Paginator(queryset, 25)
|
||||
@ -598,27 +603,18 @@ def appreciation_send_to(request, pk):
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def appreciation_acknowledge(request, pk):
|
||||
"""Acknowledge appreciation"""
|
||||
appreciation = get_object_or_404(Appreciation, pk=pk)
|
||||
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
|
||||
|
||||
# Check if user is recipient
|
||||
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||
if not (
|
||||
appreciation.recipient_content_type == user_content_type and
|
||||
appreciation.recipient_object_id == request.user.id
|
||||
):
|
||||
messages.error(request, "You can only acknowledge appreciations sent to you.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
|
||||
if appreciation.status != AppreciationStatus.SENT:
|
||||
messages.error(request, "This appreciation cannot be acknowledged in its current status.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
|
||||
# Acknowledge
|
||||
appreciation.acknowledge()
|
||||
|
||||
messages.success(request, "Appreciation acknowledged successfully.")
|
||||
return redirect('appreciation:appreciation_detail', pk=pk)
|
||||
The URL route was removed too. This view function is kept only so that
|
||||
any stale import or bookmarked URL that somehow resolves does not crash
|
||||
the worker; it redirects with a clear message. Safe to delete once no
|
||||
references remain.
|
||||
"""
|
||||
messages.error(
|
||||
request,
|
||||
_("Appreciation acknowledge is no longer supported — sending is the final step."),
|
||||
)
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
|
||||
@login_required
|
||||
@ -635,7 +631,7 @@ def appreciation_send_dept_reminder(request, pk):
|
||||
messages.error(request, _("You don't have permission to send reminders."))
|
||||
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."))
|
||||
return redirect("appreciation:appreciation_detail", pk=pk)
|
||||
|
||||
|
||||
@ -37,8 +37,6 @@ urlpatterns = [
|
||||
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-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('badges/', ui_views.my_badges_view, name='my_badges_view'),
|
||||
|
||||
|
||||
@ -211,25 +211,15 @@ class AppreciationViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def acknowledge(self, request, pk=None):
|
||||
"""Acknowledge an appreciation"""
|
||||
appreciation = self.get_object()
|
||||
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
|
||||
|
||||
# Check if user is the recipient
|
||||
user_content_type = ContentType.objects.get_for_model(request.user)
|
||||
if not (
|
||||
appreciation.recipient_content_type == user_content_type
|
||||
and appreciation.recipient_object_id == request.user.id
|
||||
):
|
||||
return Response(
|
||||
{"error": "You can only acknowledge appreciations sent to you"}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Acknowledge
|
||||
appreciation.acknowledge()
|
||||
|
||||
# Serialize and return
|
||||
serializer = AppreciationSerializer(appreciation)
|
||||
return Response(serializer.data)
|
||||
Kept as a 410 Gone endpoint so old API clients get a clear error
|
||||
instead of a 404. Will be removed in a future release.
|
||||
"""
|
||||
return Response(
|
||||
{"error": "Appreciation acknowledge is no longer supported — SENT is terminal."},
|
||||
status=status.HTTP_410_GONE,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def my_appreciations(self, request):
|
||||
|
||||
148
apps/complaints/test_workflow_realignment.py
Normal file
148
apps/complaints/test_workflow_realignment.py
Normal 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"
|
||||
@ -1589,6 +1589,7 @@ def complaint_change_status(request, pk):
|
||||
new_status = request.POST.get("status")
|
||||
note = request.POST.get("note", "")
|
||||
resolution = request.POST.get("resolution", "")
|
||||
resolution_category = request.POST.get("resolution_category", "")
|
||||
resolution_outcome = request.POST.get("resolution_outcome", "")
|
||||
resolution_outcome_other = request.POST.get("resolution_outcome_other", "")
|
||||
|
||||
@ -1600,6 +1601,7 @@ def complaint_change_status(request, pk):
|
||||
request=request,
|
||||
note=note,
|
||||
resolution=resolution,
|
||||
resolution_category=resolution_category,
|
||||
resolution_outcome=resolution_outcome,
|
||||
resolution_outcome_other=resolution_outcome_other,
|
||||
)
|
||||
@ -4475,12 +4477,10 @@ def inquiry_department_response(request, pk):
|
||||
if not (
|
||||
user.is_px_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_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.")
|
||||
return redirect("inquiries:inquiry_detail", pk=pk)
|
||||
|
||||
91
apps/feedback/test_workflow_realignment.py
Normal file
91
apps/feedback/test_workflow_realignment.py
Normal 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()
|
||||
@ -913,13 +913,30 @@ def feedback_change_status(request, pk):
|
||||
# Notify the suggester on acknowledge/close (closed-loop communication).
|
||||
# Only for suggestions with a patient-facing contact channel; internal /
|
||||
# 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:
|
||||
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(
|
||||
feedback.contact_phone,
|
||||
f"Your suggestion (ref {feedback.reference_number}) has been {label}. "
|
||||
f"Thank you for helping us improve.",
|
||||
body,
|
||||
related_object=feedback,
|
||||
)
|
||||
except Exception:
|
||||
|
||||
109
apps/observations/test_workflow_realignment.py
Normal file
109
apps/observations/test_workflow_realignment.py
Normal 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"
|
||||
@ -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_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_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_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(),
|
||||
@ -1628,6 +1628,7 @@ def observation_department_response(request, pk):
|
||||
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)
|
||||
):
|
||||
messages.error(request, "You don't have permission to respond to this observation.")
|
||||
return redirect("observations:observation_detail", pk=pk)
|
||||
|
||||
@ -2326,9 +2326,34 @@ def department_detail(request, pk):
|
||||
.filter(Q(champion__isnull=False) | Q(manager__isnull=False))
|
||||
.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)
|
||||
|
||||
|
||||
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):
|
||||
if user.is_px_admin():
|
||||
return True
|
||||
|
||||
30
conftest.py
Normal file
30
conftest.py
Normal 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
|
||||
@ -20,9 +20,7 @@
|
||||
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||
{% if appreciation.status == 'draft' %}bg-amber-100 text-amber-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 == 'acknowledged' %}bg-emerald-100 text-emerald-700
|
||||
{% else %}bg-slate-100 text-slate-600{% endif %}">
|
||||
{{ appreciation.get_status_display }}
|
||||
</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="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 "Appreciation acknowledged" %}</p>
|
||||
<p class="text-xs text-slate">{% trans "Sent on" %} {{ appreciation.sent_at|date:"Y-m-d H:i" }} · {% trans "Acknowledged" %} {{ appreciation.acknowledged_at|date:"Y-m-d H:i" }}</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" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -171,9 +171,7 @@
|
||||
<option value="">{% trans "All Status" %}</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="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="acknowledged" {% if status_filter == 'acknowledged' %}selected{% endif %}>{% trans "Acknowledged" %}</option>
|
||||
</select>
|
||||
</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">
|
||||
@ -243,9 +241,7 @@
|
||||
<span class="px-2.5 py-1 rounded-full text-[10px] font-bold uppercase
|
||||
{% if apr.status == 'draft' %}bg-amber-100 text-amber-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 == 'acknowledged' %}bg-emerald-100 text-emerald-700
|
||||
{% else %}bg-slate-100 text-slate-700{% endif %}">
|
||||
{{ apr.get_status_display }}
|
||||
</span>
|
||||
|
||||
@ -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>
|
||||
<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 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">
|
||||
<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" %}
|
||||
|
||||
@ -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>
|
||||
</form> {% endcomment %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -623,10 +627,6 @@
|
||||
<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>
|
||||
</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 %}
|
||||
@ -702,84 +702,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Respond Modal -->
|
||||
<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>
|
||||
<!-- Respond Modal (removed per workflow realignment: department owns resolution) -->
|
||||
|
||||
<!-- Assign Modal -->
|
||||
<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';
|
||||
}
|
||||
|
||||
function showRespondModal() {
|
||||
document.getElementById('respondModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function showAssignModal() {
|
||||
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() {
|
||||
const btn = document.getElementById('reanalyzeBtn');
|
||||
const content = document.getElementById('aiAnalysisContent');
|
||||
@ -1163,7 +1036,6 @@ function escapeHtml(text) { const div = document.createElement('div'); div.textC
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeModal('respondModal');
|
||||
closeModal('assignModal');
|
||||
closeModal('sendToDeptModal');
|
||||
}
|
||||
|
||||
@ -352,6 +352,10 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
<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">
|
||||
<i data-lucide="refresh-cw" class="w-3 h-3 inline-block me-1"></i>{% trans "Update" %}
|
||||
</button>
|
||||
|
||||
@ -124,22 +124,89 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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="flex items-center gap-3">
|
||||
<div class="bg-green-50 rounded-2xl border border-green-200 p-4 mb-6">
|
||||
<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><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>
|
||||
{% 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">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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="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 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@ -239,6 +239,35 @@
|
||||
</div>
|
||||
{% 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 %}
|
||||
<!-- Reject routing forms (for "Wrong Dept" buttons in the Pending Actions table) -->
|
||||
{% for inv in pending_routing_involvements %}
|
||||
|
||||
@ -60,6 +60,13 @@
|
||||
</select>
|
||||
</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' %}
|
||||
<!-- 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 %}')">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user