diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py
index 9f8fe41..5e89b08 100644
--- a/apps/accounts/forms.py
+++ b/apps/accounts/forms.py
@@ -77,7 +77,7 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
required=False,
widget=forms.PasswordInput(attrs={
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
- "placeholder": _("Leave blank to generate random password"),
+ "placeholder": _("Leave blank to send a password setup link via email"),
}),
)
password_confirm = forms.CharField(
@@ -111,6 +111,8 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
if self.user and self.user.is_hospital_admin() and self.user.hospital:
self.fields["hospital"].initial = self.user.hospital
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id)
+ elif self.user and self.user.is_px_admin() and getattr(self.request, "tenant_hospital", None):
+ self.fields["hospital"].initial = self.request.tenant_hospital
def clean_email(self):
email = self.cleaned_data.get("email")
@@ -127,18 +129,14 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
return cleaned_data
def save(self, commit=True):
- import secrets
- import string
-
user = super().save(commit=False)
password = self.cleaned_data.get("password")
- if not password:
- alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
- password = ''.join(secrets.choice(alphabet) for _ in range(16))
- self._generated_password = password
+ if password:
+ user.set_password(password)
+ else:
+ user.set_unusable_password()
- user.set_password(password)
if commit:
user.save()
selected_groups = self.cleaned_data.get("groups")
diff --git a/apps/complaints/services/complaint_service.py b/apps/complaints/services/complaint_service.py
index a398001..bf4a55c 100644
--- a/apps/complaints/services/complaint_service.py
+++ b/apps/complaints/services/complaint_service.py
@@ -81,14 +81,16 @@ class ComplaintService:
return True
if user.is_hospital_admin() and user.hospital == complaint.hospital:
return True
- if user.is_px_management() and user.hospital == complaint.hospital:
- return True
- if user.is_px_employee() and user.hospital == complaint.hospital:
- return True
- if user.is_department_manager() and user.department == complaint.department:
- return True
if complaint.assigned_to and complaint.assigned_to == user:
return True
+ # Before activation: PX staff can claim/activate the complaint
+ if not complaint.activated_at:
+ if user.is_px_management() and user.hospital == complaint.hospital:
+ return True
+ if user.is_px_employee() and user.hospital == complaint.hospital:
+ return True
+ if user.is_department_manager() and user.department == complaint.department:
+ return True
if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
return True
return False
diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py
index 1ac5f61..8a21fd5 100644
--- a/apps/complaints/tasks.py
+++ b/apps/complaints/tasks.py
@@ -2948,10 +2948,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= first_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email:
try:
+ ctx = {
+ "inquiry": inquiry,
+ "recipient_name": inquiry.assigned_to.get_full_name(),
+ "hours_remaining": max(0, round(hours_until_due)),
+ "inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
+ }
NotificationService.send_email(
email=inquiry.assigned_to.email,
subject=f"SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. Please take action.",
+ html_message=render_to_string("emails/inquiry_sla_reminder.html", ctx),
related_object=inquiry,
)
inquiry.reminder_sent_at = now
@@ -2965,10 +2972,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= second_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email:
try:
+ ctx = {
+ "inquiry": inquiry,
+ "recipient_name": inquiry.assigned_to.get_full_name(),
+ "hours_remaining": max(0, round(hours_until_due)),
+ "inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
+ }
NotificationService.send_email(
email=inquiry.assigned_to.email,
subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. URGENT action required.",
+ html_message=render_to_string("emails/inquiry_sla_second_reminder.html", ctx),
related_object=inquiry,
)
inquiry.second_reminder_sent_at = now
@@ -3233,6 +3247,14 @@ def send_inquiry_dept_response_reminders():
):
for recipient in recipients:
try:
+ ctx = {
+ "inquiry": inquiry,
+ "department_name": dept.name,
+ "sla_due_at": inquiry.dept_response_sla_due_at,
+ "hours_remaining": max(0, round(hours_remaining)),
+ "inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
+ "recipient_name": recipient.get_full_name(),
+ }
NotificationService.send_email(
email=recipient.email,
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required",
@@ -3242,6 +3264,7 @@ def send_inquiry_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response before the deadline."
),
+ html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry,
)
except Exception as e:
@@ -3260,6 +3283,14 @@ def send_inquiry_dept_response_reminders():
):
for recipient in recipients:
try:
+ ctx = {
+ "inquiry": inquiry,
+ "department_name": dept.name,
+ "sla_due_at": inquiry.dept_response_sla_due_at,
+ "hours_remaining": max(0, round(hours_remaining)),
+ "inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
+ "recipient_name": recipient.get_full_name(),
+ }
NotificationService.send_email(
email=recipient.email,
subject=f"URGENT: Inquiry #{inquiry.reference_number} - Response Overdue Soon",
@@ -3269,6 +3300,7 @@ def send_inquiry_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response immediately."
),
+ html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry,
)
except Exception as e:
@@ -3534,7 +3566,7 @@ def notify_champion_on_dept_assignment_task(involved_department_id):
department_url = f"{base}/organizations/departments/{department.pk}/"
NotificationService.send_email(
- recipient=champion_user.email,
+ email=champion_user.email,
subject=f"New Complaint Assigned - {complaint.reference_number}",
message=f"""A new complaint has been assigned to your department ({department.name}).
diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py
index 737d978..adccf64 100644
--- a/apps/complaints/ui_views.py
+++ b/apps/complaints/ui_views.py
@@ -612,6 +612,28 @@ def complaint_detail(request, pk):
ComplaintService.ensure_involved_records(complaint)
+ # Self-heal: if the complaint was sent to its primary department but no CID
+ # row exists (legacy data from before the fix), create one now so the primary
+ # department appears in the Involved Departments list.
+ if complaint.sent_to_department and complaint.department_id:
+ if not any(inv.department_id == complaint.department_id
+ for inv in complaint.involved_departments.all()):
+ from .models import ComplaintInvolvedDepartment
+ ComplaintInvolvedDepartment.objects.get_or_create(
+ complaint=complaint,
+ department=complaint.department,
+ defaults={
+ "role": "primary",
+ "is_primary": True,
+ "added_by": request.user,
+ "forwarded_at": complaint.forwarded_to_dept_at or timezone.now(),
+ "sent": True,
+ "sent_at": complaint.sent_to_department_at or timezone.now(),
+ },
+ )
+ if hasattr(complaint, "_prefetched_objects_cache"):
+ complaint._prefetched_objects_cache.pop("involved_departments", None)
+
user = request.user
if not user.is_px_admin():
if user.is_hospital_admin() and complaint.hospital != user.hospital:
@@ -747,9 +769,12 @@ def complaint_detail(request, pk):
"can_manage_actions": (
user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
- or (user.is_px_management() and user.hospital == complaint.hospital)
- or (user.is_px_employee() and user.hospital == complaint.hospital)
or (complaint.assigned_to == user)
+ or (
+ not complaint.activated_at
+ and (user.is_px_management() or user.is_px_employee())
+ and user.hospital == complaint.hospital
+ )
),
"can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
"is_active_status": complaint.is_active_status,
@@ -1195,6 +1220,35 @@ def complaint_send_to(request, pk):
now = timezone.now()
if complaint.department_id == department.pk:
+ # Create/update CID for the primary department too so it appears
+ # in the Involved Departments list and the department-detail queries.
+ involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
+ complaint=complaint,
+ department=department,
+ defaults={
+ "role": "primary",
+ "is_primary": True,
+ "added_by": user,
+ "forwarded_at": now,
+ "sent": True,
+ "sent_at": now,
+ },
+ )
+ if not created:
+ involved_dept.forwarded_at = now
+ involved_dept.sent = True
+ involved_dept.sent_at = now
+ involved_dept.routing_status = "sent"
+ involved_dept.rejected_at = None
+ involved_dept.rejected_by_staff = None
+ involved_dept.rejection_reason = ""
+ involved_dept.suggested_department = None
+ involved_dept.save(update_fields=[
+ "forwarded_at", "sent", "sent_at",
+ "routing_status", "rejected_at", "rejected_by_staff",
+ "rejection_reason", "suggested_department",
+ ])
+
complaint.sent_to_department = True
complaint.sent_to_department_at = now
if not complaint.forwarded_to_dept_at:
@@ -1371,6 +1425,123 @@ def complaint_send_to(request, pk):
}, status=500)
+@login_required
+@require_http_methods(["POST"])
+def complaint_send_dept_reminder(request, pk):
+ """Send a manual reminder to a department that hasn't responded yet.
+
+ Visible in Quick Actions after the complaint has been sent to a department.
+ The user picks which department from a modal; this endpoint sends an email +
+ SMS to that department's champion/manager and logs a ComplaintUpdate.
+ """
+ from .models import ComplaintInvolvedDepartment, ComplaintUpdate
+ from apps.notifications.services import NotificationService, get_email_header_html
+ from apps.organizations.department_contacts import get_champion_and_manager
+ from apps.core.utils import build_public_track_url
+
+ complaint = get_object_or_404(Complaint, pk=pk)
+ user = request.user
+
+ if not can_manage_complaint(user, complaint):
+ messages.error(request, _("You don't have permission to send reminders."))
+ return redirect("complaints:complaint_detail", pk=pk)
+
+ inv_dept_id = request.POST.get("involved_department_id", "").strip()
+ note = request.POST.get("note", "").strip()
+
+ if not inv_dept_id:
+ messages.error(request, _("Please select a department."))
+ return redirect("complaints:complaint_detail", pk=pk)
+
+ involved_dept = get_object_or_404(
+ ComplaintInvolvedDepartment,
+ pk=inv_dept_id,
+ complaint=complaint,
+ sent=True,
+ response_submitted=False,
+ )
+ department = involved_dept.department
+
+ targets = get_champion_and_manager(department)
+ if not targets:
+ messages.error(
+ request,
+ _(f"Cannot send reminder to {department.get_localized_name()}. This department has no champion or manager assigned."),
+ )
+ return redirect("complaints:complaint_detail", pk=pk)
+
+ track_url = build_public_track_url("complaint", complaint.reference_number)
+ notified = []
+ sent_any = False
+
+ for target in targets:
+ email = target.get("email") or ""
+ phone = target.get("phone") or ""
+ label = target.get("label") or ""
+ display_name = target.get("display_name") or label
+
+ if email:
+ try:
+ NotificationService.send_email(
+ email=email,
+ subject=f"Reminder: Complaint #{complaint.reference_number} - Response Required",
+ message=(
+ f"This is a reminder that complaint #{complaint.reference_number} "
+ f"is awaiting your department's response.\n\n"
+ f"Track: {track_url}"
+ ),
+ html_message=f"""
+
+ {get_email_header_html()}
+
+
Reminder: Response Required
+
This is a reminder that complaint #{complaint.reference_number} is still awaiting your department's response.
+
Department: {department.name}
+
+
+
+""",
+ related_object=complaint,
+ )
+ sent_any = True
+ except Exception:
+ pass
+ if phone:
+ try:
+ NotificationService.send_sms(
+ phone,
+ f"PX360: Reminder - Complaint #{complaint.reference_number} sent to {department.name} is awaiting your response. Track: {track_url}",
+ related_object=complaint,
+ )
+ sent_any = True
+ except Exception:
+ pass
+
+ notified.append(f"{display_name} ({label})")
+
+ if sent_any:
+ ComplaintUpdate.objects.create(
+ complaint=complaint,
+ update_type="communication",
+ message=f"Manual reminder sent to {department.get_localized_name()} — {', '.join(notified)}",
+ created_by=user,
+ metadata={
+ "event_type": "manual_reminder",
+ "department_id": str(department.id),
+ "involved_department_id": str(involved_dept.id),
+ "recipients": notified,
+ "note": note,
+ },
+ )
+ messages.success(request, _(f"Reminder sent to {department.get_localized_name()}."))
+ else:
+ messages.error(request, _(f"Could not send reminder to {department.get_localized_name()}. No valid contact info."))
+
+ return redirect("complaints:complaint_detail", pk=pk)
+
+
@login_required
@require_http_methods(["POST"])
def complaint_change_status(request, pk):
diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py
index 9e4ce37..b18164a 100644
--- a/apps/complaints/urls.py
+++ b/apps/complaints/urls.py
@@ -180,6 +180,7 @@ urlpatterns = [
path("departments//reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"),
# Unified Send To (Person or Department) - AJAX
path("/send-to/", ui_views.complaint_send_to, name="complaint_send_to"),
+ path("/send-dept-reminder/", ui_views.complaint_send_dept_reminder, name="complaint_send_dept_reminder"),
# Collect Feedback (champion/manager compose questions for staff)
path("/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"),
# Involved Staff Management
diff --git a/apps/complaints/views.py b/apps/complaints/views.py
index 5c724a6..3419328 100644
--- a/apps/complaints/views.py
+++ b/apps/complaints/views.py
@@ -3665,6 +3665,10 @@ def complaint_explanation_form(request, complaint_id, token):
)
investigation.final_reply = explanation_text
investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS
+ investigation.negligence_finding = negligence_finding
+ investigation.policy_issue_finding = policy_issue_finding
+ investigation.requires_improvement_project = requires_improvement_project
+ investigation.improvement_project_note = improvement_project_note
import random
@@ -3673,6 +3677,8 @@ def complaint_explanation_form(request, complaint_id, token):
investigation.otp_sent_at = timezone.now()
investigation.save(update_fields=[
"final_reply", "status", "otp_code", "otp_sent_at",
+ "negligence_finding", "policy_issue_finding",
+ "requires_improvement_project", "improvement_project_note",
])
sent_channels = []
@@ -3977,6 +3983,7 @@ def complaint_explanation_form(request, complaint_id, token):
# Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page)
otp_sent_on_get = False
+ draft_ctx = {}
if not explanation.is_used:
from apps.complaints.models import ChampionInvestigation, InvestigationStatus
from datetime import timedelta
@@ -3987,6 +3994,14 @@ def complaint_explanation_form(request, complaint_id, token):
).exclude(otp_code="").first()
if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10):
otp_sent_on_get = True
+ draft_ctx = {
+ "final_reply": existing.final_reply or "",
+ "negligence_finding": existing.negligence_finding or "",
+ "policy_issue_finding": existing.policy_issue_finding or "",
+ "requires_improvement_project": existing.requires_improvement_project or "",
+ "improvement_project_note": existing.improvement_project_note or "",
+ "consent_checked": True,
+ }
return render(
request,
@@ -4005,6 +4020,7 @@ def complaint_explanation_form(request, complaint_id, token):
"token": explanation.token,
})
),
+ **draft_ctx,
},
)
diff --git a/apps/core/config_views.py b/apps/core/config_views.py
index 9c738a8..4522136 100644
--- a/apps/core/config_views.py
+++ b/apps/core/config_views.py
@@ -212,9 +212,10 @@ def user_create(request):
form = UserCreateForm(request.POST, request=request)
if form.is_valid():
user = form.save()
- generated_password = getattr(form, '_generated_password', None)
staff_id = request.POST.get("staff_id")
+ create_staff = request.POST.get("create_staff") == "on"
+
if staff_id:
from apps.organizations.models import Staff
try:
@@ -226,29 +227,46 @@ def user_create(request):
))
except Staff.DoesNotExist:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
+ elif create_staff:
+ from apps.organizations.services import StaffService
+ staff_type = request.POST.get("staff_type", "other")
+ job_title = request.POST.get("staff_job_title", "")
+ try:
+ staff = StaffService.create_staff_for_user(
+ user, staff_type=staff_type, job_title=job_title, request=request
+ )[0]
+ messages.success(request, _("User '{}' created with staff record '{}'.").format(
+ user.get_full_name(), staff.get_full_name()
+ ))
+ except ValueError as e:
+ messages.warning(request, _("User '{}' created but staff record creation failed: {}").format(
+ user.get_full_name(), str(e)
+ ))
else:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
+ password_was_set = bool(form.cleaned_data.get("password"))
+
try:
base_url = f"{request.scheme}://{request.get_host()}"
- reset_token = PasswordResetTokenService.create_reset_token(user)
- reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
+ subject = _("Your PX360 Account Has Been Created")
- if generated_password:
+ if password_was_set:
+ login_url = f"{base_url}/accounts/login/"
html_message = render_to_string(
"config/emails/user_created_email.html",
- {"user": user, "reset_url": reset_url, "temp_password": generated_password},
+ {"user": user, "login_url": login_url},
request=request,
)
plain_message = (
f"Dear {user.get_full_name()},\n\n"
f"An account has been created for you on PX360.\n\n"
- f"Your temporary password: {generated_password}\n\n"
- f"For security, please set your own password using the link below:\n{reset_url}\n\n"
- f"This link expires in 24 hours."
+ f"You can log in at: {login_url}\n\n"
+ f"Please contact your administrator for your login credentials."
)
- subject = _("Your PX360 Account Has Been Created")
else:
+ reset_token = PasswordResetTokenService.create_reset_token(user)
+ reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
html_message = render_to_string(
"config/emails/reset_password_email.html",
{"user": user, "reset_url": reset_url},
@@ -260,7 +278,6 @@ def user_create(request):
f"Use the link below to set your password:\n{reset_url}\n\n"
f"This link expires in 24 hours."
)
- subject = _("Your PX360 Account Has Been Created")
NotificationService.send_email(
email=user.email,
@@ -270,8 +287,8 @@ def user_create(request):
user=user,
notification_type="system",
)
- if generated_password:
- messages.info(request, _("Credentials sent to {}.").format(user.email))
+ if password_was_set:
+ messages.info(request, _("Welcome email sent to {}.").format(user.email))
else:
messages.info(request, _("Password setup link sent to {}.").format(user.email))
except Exception as e:
diff --git a/apps/integrations/api_serializers.py b/apps/integrations/api_serializers.py
index d46f58e..183301f 100644
--- a/apps/integrations/api_serializers.py
+++ b/apps/integrations/api_serializers.py
@@ -363,7 +363,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving an appreciation."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
- reference_number = serializers.SerializerMethodField()
class Meta:
model = Appreciation
@@ -379,11 +378,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
- def get_reference_number(self, obj):
- if obj.metadata and "reference_number" in obj.metadata:
- return obj.metadata["reference_number"]
- return None
-
# ---------------------------------------------------------------------------
# Suggestion (Feedback)
@@ -433,7 +427,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving a suggestion."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
- reference_number = serializers.SerializerMethodField()
class Meta:
model = Feedback
@@ -455,11 +448,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
]
read_only_fields = fields
- def get_reference_number(self, obj):
- if obj.metadata and "reference_number" in obj.metadata:
- return obj.metadata["reference_number"]
- return None
-
class ExternalComplaintSatisfactionSerializer(serializers.Serializer):
satisfaction = serializers.ChoiceField(
diff --git a/apps/integrations/api_views.py b/apps/integrations/api_views.py
index b0fbc08..472db84 100644
--- a/apps/integrations/api_views.py
+++ b/apps/integrations/api_views.py
@@ -155,7 +155,7 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
status=status.HTTP_403_FORBIDDEN,
)
- # Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
+ # Reference number generated by Complaint.save() (unified CMP-YYYYMM-NNNN)
complaint = Complaint.objects.create(
patient=None,
@@ -187,6 +187,8 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
message="Complaint submitted via external API.",
)
+ reference_number = complaint.reference_number
+
# Trigger background tasks
try:
from apps.complaints.tasks import analyze_complaint_with_ai, notify_staff_new_item
@@ -596,13 +598,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
status=status.HTTP_403_FORBIDDEN,
)
- import uuid
- from datetime import datetime
-
- today = datetime.now().strftime("%Y%m%d")
- random_suffix = str(uuid.uuid4().int)[:6]
- reference_number = f"APR-{today}-{random_suffix}"
-
appreciation = Appreciation.objects.create(
hospital=data["hospital"],
message_en=data["message"],
@@ -613,7 +608,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
visibility=AppreciationVisibility.PUBLIC,
metadata={
"source": "external_api",
- "reference_number": reference_number,
"submitted_by_name": data["contact_name"],
"submitted_by_phone": data["contact_phone"],
},
@@ -636,7 +630,7 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
return Response(
{
"success": True,
- "reference_number": reference_number,
+ "reference_number": appreciation.reference_number,
"status": appreciation.status,
},
status=status.HTTP_201_CREATED,
@@ -646,22 +640,22 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView):
"""
GET /api/v1/external/appreciations/
- GET /api/v1/external/appreciations//
+ GET /api/v1/external/appreciations//
"""
entity_name = "appreciations"
- def get(self, request, pk=None):
+ def get(self, request, reference_number=None):
access_err = self.check_entity_access()
if access_err:
return access_err
- if pk:
- return self._retrieve(request, pk)
+ if reference_number:
+ return self._retrieve(request, reference_number)
return self._list(request)
- def _retrieve(self, request, pk):
- qs = Appreciation.all_objects.filter(pk=pk)
+ def _retrieve(self, request, reference_number):
+ qs = Appreciation.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs)
appreciation = qs.first()
if not appreciation:
@@ -734,13 +728,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
"other": FeedbackCategory.OTHER,
}
- import uuid as _uuid
- from datetime import datetime
-
- today = datetime.now().strftime("%Y%m%d")
- random_suffix = str(_uuid.uuid4().int)[:6]
- reference_number = f"SG-{today}-{random_suffix}"
-
title = data.get("title") or data["message"][:100]
feedback = Feedback.objects.create(
@@ -756,7 +743,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
status=FeedbackStatus.SUBMITTED,
metadata={
"source": "external_api",
- "reference_number": reference_number,
"suggestion_area": data.get("category", "general"),
},
)
@@ -780,7 +766,7 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
return Response(
{
"success": True,
- "reference_number": reference_number,
+ "reference_number": feedback.reference_number,
"status": feedback.status,
},
status=status.HTTP_201_CREATED,
@@ -790,22 +776,22 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView):
"""
GET /api/v1/external/suggestions/
- GET /api/v1/external/suggestions//
+ GET /api/v1/external/suggestions//
"""
entity_name = "suggestions"
- def get(self, request, pk=None):
+ def get(self, request, reference_number=None):
access_err = self.check_entity_access()
if access_err:
return access_err
- if pk:
- return self._retrieve(request, pk)
+ if reference_number:
+ return self._retrieve(request, reference_number)
return self._list(request)
- def _retrieve(self, request, pk):
- qs = Feedback.all_objects.filter(pk=pk)
+ def _retrieve(self, request, reference_number):
+ qs = Feedback.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs)
feedback = qs.first()
if not feedback:
diff --git a/apps/integrations/urls_external.py b/apps/integrations/urls_external.py
index b6650f4..f192f01 100644
--- a/apps/integrations/urls_external.py
+++ b/apps/integrations/urls_external.py
@@ -119,7 +119,7 @@ urlpatterns = [
name="external-appreciation-list",
),
path(
- "appreciations//",
+ "appreciations//",
ExternalAppreciationRetrieveView.as_view(),
name="external-appreciation-detail",
),
@@ -135,7 +135,7 @@ urlpatterns = [
name="external-suggestion-list",
),
path(
- "suggestions//",
+ "suggestions//",
ExternalSuggestionRetrieveView.as_view(),
name="external-suggestion-detail",
),
diff --git a/apps/notifications/services.py b/apps/notifications/services.py
index 5052b55..b09c593 100644
--- a/apps/notifications/services.py
+++ b/apps/notifications/services.py
@@ -312,6 +312,18 @@ class NotificationService:
Returns:
NotificationLog instance
"""
+ # Auto-wrap plain-text messages in the branded base email template so
+ # every notification has the hospital logo, card, and footer. Callers
+ # that already supply html_message are left untouched.
+ if html_message is None and message:
+ from django.template.loader import render_to_string as _render_to_string
+
+ content_html = "" + message.replace("\n\n", "
").replace("\n", "
\n") + "
"
+ html_message = _render_to_string(
+ "emails/simple_email.html",
+ {"subject": subject, "content_html": content_html},
+ )
+
from apps.accounts.models import User
# Check if Email API is enabled and use it (simulator or external API)
diff --git a/apps/notifications/tests_email.py b/apps/notifications/tests_email.py
new file mode 100644
index 0000000..fe94f34
--- /dev/null
+++ b/apps/notifications/tests_email.py
@@ -0,0 +1,129 @@
+"""
+Tests for the email reminder template consistency fixes:
+ - Part D: NotificationService.send_email auto-wraps plain-text in branded template
+ - Part A: dept-champion notification uses email= (not recipient=) kwarg
+ - Part B/C: branded reminder templates render with the expected context
+"""
+from unittest.mock import patch
+
+from django.template.loader import render_to_string
+from django.test import TestCase
+
+from apps.notifications.services import NotificationService
+
+
+class AutoWrapBrandedTemplateTests(TestCase):
+ """Part D — plain-text messages are auto-wrapped in the branded template."""
+
+ @patch("apps.notifications.services.send_mail")
+ def test_plain_text_gets_branded_html(self, mock_send_mail):
+ NotificationService.send_email(
+ email="patient@example.com",
+ subject="Test Subject",
+ message="Line one.\n\nLine two.\nLine three.",
+ )
+ # send_mail was called with an html_message
+ _, kwargs = mock_send_mail.call_args
+ self.assertIsNotNone(kwargs.get("html_message"))
+ html = kwargs["html_message"]
+ # The branded template contributes these structural markers
+ self.assertIn("email-container", html) # the 600px card from base_email_template
+ self.assertIn("", html) # single newline ->
+ self.assertIn("", html) # double newline -> new paragraph
+
+ @patch("apps.notifications.services.send_mail")
+ def test_explicit_html_message_is_not_overwritten(self, mock_send_mail):
+ """Callers that already provide html_message keep it as-is (no double-wrapping)."""
+ custom_html = "
My custom layout
"
+ NotificationService.send_email(
+ email="patient@example.com",
+ subject="Test",
+ message="Plain text fallback",
+ html_message=custom_html,
+ )
+ _, kwargs = mock_send_mail.call_args
+ self.assertEqual(kwargs["html_message"], custom_html)
+
+
+class DeptChampionNotificationKwargTests(TestCase):
+ """Part A — the recipient= bug is fixed (send_email uses email= kwarg).
+
+ We verify indirectly: send_email no longer receives an unexpected 'recipient'
+ kwarg, which used to cause a TypeError swallowed by try/except.
+ """
+
+ @patch("apps.notifications.services.send_mail")
+ def test_send_email_signature_rejects_recipient_kwarg(self, mock_mail):
+ """The send_email method must not accept 'recipient' as a kwarg (the bug).
+
+ Python validates kwargs at call time, so TypeError fires before the
+ body runs — we must NOT mock send_email itself.
+ """
+ with self.assertRaises(TypeError):
+ NotificationService.send_email(
+ recipient="someone@example.com", # wrong kwarg — should TypeError
+ subject="Test",
+ message="Test",
+ )
+
+
+class BrandedReminderTemplateTests(TestCase):
+ """Parts B & C — the reminder templates extend the base and render branded HTML."""
+
+ def _assert_branded(self, html):
+ """Structural markers contributed by base_email_template.html."""
+ self.assertIn("/",
- "host": ["{{base_url}}"],
- "path": ["api", "v1", "external", "appreciations", "", ""]
+ "raw": "{{base_url}}/api/v1/external/appreciations//",
+ "host": ["{{base_url}}"],
+ "path": ["api", "v1", "external", "appreciations", "", ""]
}
},
"response": []
@@ -669,7 +669,7 @@
"name": "Created",
"status": "Created",
"code": 201,
- "body": "{\n \"success\": true,\n \"reference_number\": \"SG-20260609-316316\",\n \"status\": \"submitted\"\n}"
+ "body": "{\n \"success\": true,\n \"reference_number\": \"SGT-202607-0001\",\n \"status\": \"submitted\"\n}"
}
]
},
@@ -714,9 +714,9 @@
}
],
"url": {
- "raw": "{{base_url}}/api/v1/external/suggestions//",
- "host": ["{{base_url}}"],
- "path": ["api", "v1", "external", "suggestions", "", ""]
+ "raw": "{{base_url}}/api/v1/external/suggestions//",
+ "host": ["{{base_url}}"],
+ "path": ["api", "v1", "external", "suggestions", "", ""]
}
},
"response": []
diff --git a/docs/external-api.md b/docs/external-api.md
index 8b6c838..067166f 100644
--- a/docs/external-api.md
+++ b/docs/external-api.md
@@ -452,7 +452,7 @@ POST /api/v1/external/appreciations/
```json
{
"success": true,
- "reference_number": "APR-20260609-239270",
+ "reference_number": "APR-202607-0001",
"status": "draft"
}
```
@@ -461,7 +461,7 @@ POST /api/v1/external/appreciations/
```
GET /api/v1/external/appreciations/list/
-GET /api/v1/external/appreciations//
+GET /api/v1/external/appreciations//
```
**Retrieve Response:**
@@ -469,7 +469,7 @@ GET /api/v1/external/appreciations//
```json
{
"id": "uuid",
- "reference_number": "APR-20260609-239270",
+ "reference_number": "APR-202607-0001",
"message_en": "Dr. Ahmed was incredibly kind and thorough.",
"status": "draft",
"hospital_name": "Al Nuzha",
@@ -506,7 +506,7 @@ POST /api/v1/external/suggestions/
```json
{
"success": true,
- "reference_number": "SG-20260609-316316",
+ "reference_number": "SGT-202607-0001",
"status": "submitted"
}
```
@@ -515,7 +515,7 @@ POST /api/v1/external/suggestions/
```
GET /api/v1/external/suggestions/list/
-GET /api/v1/external/suggestions//
+GET /api/v1/external/suggestions//
```
**Retrieve Response:**
@@ -523,7 +523,7 @@ GET /api/v1/external/suggestions//
```json
{
"id": "uuid",
- "reference_number": "SG-20260609-316316",
+ "reference_number": "SGT-202607-0001",
"title": "Digital queue system",
"message": "Please implement a digital queue management system.",
"category": "technology",
@@ -669,5 +669,5 @@ All list endpoints return paginated results:
| Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` |
| Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` |
| Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) |
-| Appreciation | `APR-` | `APR-YYYYMMDD-XXXXXX` |
-| Suggestion | `SG-` | `SG-YYYYMMDD-XXXXXX` |
+| Appreciation | `APR-` | `APR-YYYYMM-NNNN` |
+| Suggestion | `SGT-` | `SGT-YYYYMM-NNNN` |
diff --git a/docs/workflows/appreciations.md b/docs/workflows/appreciations.md
index 4cb96b0..186e4a2 100644
--- a/docs/workflows/appreciations.md
+++ b/docs/workflows/appreciations.md
@@ -39,7 +39,7 @@ So: **a recognition engine whose originating actors can be (a) PX/hospital staff
**(b) Public portal** (`ui_views.py:1196`): Inputs `contact_name`, `contact_phone`, `message`, `hospital`, optional `staff_name`/`department`/`section`. Required: name, phone, message, hospital (`ui_views.py:1224`). Creates with `status=DRAFT`, `visibility=PUBLIC`, `is_anonymous=False`, `category=None`, metadata `{source:"public_form", ...}` (`ui_views.py:1239`). Fires `notify_staff_new_item.delay("appreciation", ...)` (`ui_views.py:1258`). Returns `reference_number`.
-**(c) External API** (`integrations/api_views.py:606`): Creates DRAFT, visibility PUBLIC, metadata `{source:"external_api", ...}`. Builds its own `APR-YYYYMMDD-` (`api_views.py:602`) — informational, since `save()` also generates the canonical `reference_number`. Fires `notify_staff_new_item`.
+**(c) External API** (`integrations/api_views.py:606`): Creates DRAFT, visibility PUBLIC, metadata `{source:"external_api", ...}`. Returns the canonical `reference_number` generated by `save()` (`APR-YYYYMM-NNNN`); the same value is used to retrieve the record via `GET /api/v1/external/appreciations//`. Fires `notify_staff_new_item`.
**(d) REST API create** (`views.py:143`, serializer `serializers.py:132`):
Required: `recipient_type` (`['user','staff']`), `recipient_id` (UUID), `message_en`, `hospital_id`. Optional: `category_id`, `message_ar`, `visibility` (default PRIVATE), `is_anonymous`, `department_id`. Validation: recipient must belong to hospital; category hospital-compatible. `AppreciationViewSet.create` resolves a `User`/`Staff` via `ContentType`, creates the record (status DRAFT), then **immediately calls `appreciation.send()`** (`views.py:198`).
diff --git a/docs/workflows/suggestions.md b/docs/workflows/suggestions.md
index 4ebf475..97b4968 100644
--- a/docs/workflows/suggestions.md
+++ b/docs/workflows/suggestions.md
@@ -37,7 +37,7 @@ Post-save (`views.py:992-1024`): `patient=None` at creation; linked **asynchrono
### 2.3 Channel C — External API (machine-to-machine)
- View: `ExternalSuggestionCreateView` (`apps/integrations/api_views.py:700`), `POST /api/v1/external/suggestions/`.
-- Auth via API key; hospital must match scope. Generates its own ephemeral `SG-{YYYYMMDD}-{random6}` in `metadata` (`api_views.py:740`) — **not** the canonical `reference_number` column (which `save()` still populates). Same post-save tasks (`api_views.py:765`).
+- Auth via API key; hospital must match scope. Returns the canonical `reference_number` generated by `save()` (`SGT-YYYYMM-NNNN`); the same value is used to retrieve the record via `GET /api/v1/external/suggestions//`. Same post-save tasks (`api_views.py:765`).
### 2.4 Channel D — PX Source-User Portal
- View: `source_user_create_suggestion` (`apps/px_sources/ui_views.py:1387`); `@login_required`; guarded by `source_user.can_create_suggestions`.
@@ -302,7 +302,7 @@ Raw-unit model of the IT-export pipeline. Imported in monthly batches via `Comme
## Appendix — Flagged gaps / ambiguities
1. **Reopen unreachable via UI** after closure (Quick Actions hidden when `closed`, `feedback_detail.html:299`). CLOSED→REOPENED allowed by map but no staff UI button.
-2. **`reference_number` divergence** — `save()` generates canonical `SGT-YYYYMM-NNNN`; public endpoint returns different `SG-{uuid[:8]}` (`views.py:1026`); external API generates `SG-{YYYYMMDD}-{random6}` in metadata only. Public cannot track by real reference.
+2. **`reference_number` divergence (external API resolved)** — `save()` generates canonical `SGT-YYYYMM-NNNN`; the external API now returns and retrieves by this canonical reference. **Public portal still diverges**: the public submit endpoint returns a different `SG-{uuid[:8]}` (`views.py:1026`), so the public cannot track by the real reference via the web portal.
3. **`FeedbackForm` unused for creation** — `feedback_create` builds manually, bypassing form `clean()`.
4. **Transitions unenforced at DB/model/admin** — only `feedback_change_status` enforces.
5. **No outbound communication to suggester** by design.
diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html
index 04c1365..073f89b 100644
--- a/templates/complaints/complaint_detail.html
+++ b/templates/complaints/complaint_detail.html
@@ -134,7 +134,7 @@
class="text-slate hover:text-navy px-3 py-2 text-sm font-semibold flex items-center gap-2 border rounded-lg hover:bg-light transition">
{% trans "PDF View" %}
{% endcomment %}
- {% if can_edit and complaint.is_active_status and complaint.activated_at and complaint.assigned_to == current_user %}
+ {% if can_admin or can_edit and complaint.is_active_status and complaint.activated_at and complaint.assigned_to == current_user %}
@@ -390,25 +390,25 @@
-