This commit is contained in:
ismail 2026-07-12 11:18:20 +03:00
parent 6baa34dec3
commit fe0a643607
25 changed files with 871 additions and 160 deletions

View File

@ -77,7 +77,7 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
required=False, required=False,
widget=forms.PasswordInput(attrs={ 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", "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( 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: if self.user and self.user.is_hospital_admin() and self.user.hospital:
self.fields["hospital"].initial = self.user.hospital self.fields["hospital"].initial = self.user.hospital
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id) 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): def clean_email(self):
email = self.cleaned_data.get("email") email = self.cleaned_data.get("email")
@ -127,18 +129,14 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
return cleaned_data return cleaned_data
def save(self, commit=True): def save(self, commit=True):
import secrets
import string
user = super().save(commit=False) user = super().save(commit=False)
password = self.cleaned_data.get("password") password = self.cleaned_data.get("password")
if not password: if password:
alphabet = string.ascii_letters + string.digits + "!@#$%^&*" user.set_password(password)
password = ''.join(secrets.choice(alphabet) for _ in range(16)) else:
self._generated_password = password user.set_unusable_password()
user.set_password(password)
if commit: if commit:
user.save() user.save()
selected_groups = self.cleaned_data.get("groups") selected_groups = self.cleaned_data.get("groups")

View File

@ -81,14 +81,16 @@ class ComplaintService:
return True return True
if user.is_hospital_admin() and user.hospital == complaint.hospital: if user.is_hospital_admin() and user.hospital == complaint.hospital:
return True 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: if complaint.assigned_to and complaint.assigned_to == user:
return True 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(): if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
return True return True
return False return False

View File

@ -2948,10 +2948,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= first_reminder_hours: if hours_since_creation >= first_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email: if inquiry.assigned_to and inquiry.assigned_to.email:
try: 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( NotificationService.send_email(
email=inquiry.assigned_to.email, email=inquiry.assigned_to.email,
subject=f"SLA Reminder - Inquiry #{inquiry.id}", subject=f"SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. Please take action.", 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, related_object=inquiry,
) )
inquiry.reminder_sent_at = now inquiry.reminder_sent_at = now
@ -2965,10 +2972,17 @@ def send_inquiry_sla_reminders():
if hours_since_creation >= second_reminder_hours: if hours_since_creation >= second_reminder_hours:
if inquiry.assigned_to and inquiry.assigned_to.email: if inquiry.assigned_to and inquiry.assigned_to.email:
try: 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( NotificationService.send_email(
email=inquiry.assigned_to.email, email=inquiry.assigned_to.email,
subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}", subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}",
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. URGENT action required.", 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, related_object=inquiry,
) )
inquiry.second_reminder_sent_at = now inquiry.second_reminder_sent_at = now
@ -3233,6 +3247,14 @@ def send_inquiry_dept_response_reminders():
): ):
for recipient in recipients: for recipient in recipients:
try: 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( NotificationService.send_email(
email=recipient.email, email=recipient.email,
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required", 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"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response before the deadline." f"Please submit your response before the deadline."
), ),
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry, related_object=inquiry,
) )
except Exception as e: except Exception as e:
@ -3260,6 +3283,14 @@ def send_inquiry_dept_response_reminders():
): ):
for recipient in recipients: for recipient in recipients:
try: 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( NotificationService.send_email(
email=recipient.email, email=recipient.email,
subject=f"URGENT: Inquiry #{inquiry.reference_number} - Response Overdue Soon", 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"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response immediately." f"Please submit your response immediately."
), ),
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
related_object=inquiry, related_object=inquiry,
) )
except Exception as e: 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}/" department_url = f"{base}/organizations/departments/{department.pk}/"
NotificationService.send_email( NotificationService.send_email(
recipient=champion_user.email, email=champion_user.email,
subject=f"New Complaint Assigned - {complaint.reference_number}", subject=f"New Complaint Assigned - {complaint.reference_number}",
message=f"""A new complaint has been assigned to your department ({department.name}). message=f"""A new complaint has been assigned to your department ({department.name}).

View File

@ -612,6 +612,28 @@ def complaint_detail(request, pk):
ComplaintService.ensure_involved_records(complaint) 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 user = request.user
if not user.is_px_admin(): if not user.is_px_admin():
if user.is_hospital_admin() and complaint.hospital != user.hospital: if user.is_hospital_admin() and complaint.hospital != user.hospital:
@ -747,9 +769,12 @@ def complaint_detail(request, pk):
"can_manage_actions": ( "can_manage_actions": (
user.is_px_admin() user.is_px_admin()
or (user.is_hospital_admin() and user.hospital == complaint.hospital) 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 (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), "can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
"is_active_status": complaint.is_active_status, "is_active_status": complaint.is_active_status,
@ -1195,6 +1220,35 @@ def complaint_send_to(request, pk):
now = timezone.now() now = timezone.now()
if complaint.department_id == department.pk: 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 = True
complaint.sent_to_department_at = now complaint.sent_to_department_at = now
if not complaint.forwarded_to_dept_at: if not complaint.forwarded_to_dept_at:
@ -1371,6 +1425,123 @@ def complaint_send_to(request, pk):
}, status=500) }, 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"""
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
{get_email_header_html()}
<div style="padding: 20px;">
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Reminder: Response Required</h2>
<p style="margin: 0 0 12px 0;">This is a reminder that complaint <strong>#{complaint.reference_number}</strong> is still awaiting your department's response.</p>
<p style="margin: 0 0 12px 0;"><strong>Department:</strong> {department.name}</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Track Complaint</a>
</div>
</div>
</div>
""",
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 @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def complaint_change_status(request, pk): def complaint_change_status(request, pk):

View File

@ -180,6 +180,7 @@ urlpatterns = [
path("departments/<uuid:pk>/reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"), path("departments/<uuid:pk>/reject-routing/", ui_views.involved_department_reject_routing, name="involved_department_reject_routing"),
# Unified Send To (Person or Department) - AJAX # Unified Send To (Person or Department) - AJAX
path("<uuid:pk>/send-to/", ui_views.complaint_send_to, name="complaint_send_to"), path("<uuid:pk>/send-to/", ui_views.complaint_send_to, name="complaint_send_to"),
path("<uuid:pk>/send-dept-reminder/", ui_views.complaint_send_dept_reminder, name="complaint_send_dept_reminder"),
# Collect Feedback (champion/manager compose questions for staff) # Collect Feedback (champion/manager compose questions for staff)
path("<uuid:pk>/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"), path("<uuid:pk>/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"),
# Involved Staff Management # Involved Staff Management

View File

@ -3665,6 +3665,10 @@ def complaint_explanation_form(request, complaint_id, token):
) )
investigation.final_reply = explanation_text investigation.final_reply = explanation_text
investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS 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 import random
@ -3673,6 +3677,8 @@ def complaint_explanation_form(request, complaint_id, token):
investigation.otp_sent_at = timezone.now() investigation.otp_sent_at = timezone.now()
investigation.save(update_fields=[ investigation.save(update_fields=[
"final_reply", "status", "otp_code", "otp_sent_at", "final_reply", "status", "otp_code", "otp_sent_at",
"negligence_finding", "policy_issue_finding",
"requires_improvement_project", "improvement_project_note",
]) ])
sent_channels = [] 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) # Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page)
otp_sent_on_get = False otp_sent_on_get = False
draft_ctx = {}
if not explanation.is_used: if not explanation.is_used:
from apps.complaints.models import ChampionInvestigation, InvestigationStatus from apps.complaints.models import ChampionInvestigation, InvestigationStatus
from datetime import timedelta from datetime import timedelta
@ -3987,6 +3994,14 @@ def complaint_explanation_form(request, complaint_id, token):
).exclude(otp_code="").first() ).exclude(otp_code="").first()
if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10): if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10):
otp_sent_on_get = True 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( return render(
request, request,
@ -4005,6 +4020,7 @@ def complaint_explanation_form(request, complaint_id, token):
"token": explanation.token, "token": explanation.token,
}) })
), ),
**draft_ctx,
}, },
) )

View File

@ -212,9 +212,10 @@ def user_create(request):
form = UserCreateForm(request.POST, request=request) form = UserCreateForm(request.POST, request=request)
if form.is_valid(): if form.is_valid():
user = form.save() user = form.save()
generated_password = getattr(form, '_generated_password', None)
staff_id = request.POST.get("staff_id") staff_id = request.POST.get("staff_id")
create_staff = request.POST.get("create_staff") == "on"
if staff_id: if staff_id:
from apps.organizations.models import Staff from apps.organizations.models import Staff
try: try:
@ -226,29 +227,46 @@ def user_create(request):
)) ))
except Staff.DoesNotExist: except Staff.DoesNotExist:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name())) 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: else:
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name())) messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
password_was_set = bool(form.cleaned_data.get("password"))
try: try:
base_url = f"{request.scheme}://{request.get_host()}" base_url = f"{request.scheme}://{request.get_host()}"
reset_token = PasswordResetTokenService.create_reset_token(user) subject = _("Your PX360 Account Has Been Created")
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
if generated_password: if password_was_set:
login_url = f"{base_url}/accounts/login/"
html_message = render_to_string( html_message = render_to_string(
"config/emails/user_created_email.html", "config/emails/user_created_email.html",
{"user": user, "reset_url": reset_url, "temp_password": generated_password}, {"user": user, "login_url": login_url},
request=request, request=request,
) )
plain_message = ( plain_message = (
f"Dear {user.get_full_name()},\n\n" f"Dear {user.get_full_name()},\n\n"
f"An account has been created for you on PX360.\n\n" f"An account has been created for you on PX360.\n\n"
f"Your temporary password: {generated_password}\n\n" f"You can log in at: {login_url}\n\n"
f"For security, please set your own password using the link below:\n{reset_url}\n\n" f"Please contact your administrator for your login credentials."
f"This link expires in 24 hours."
) )
subject = _("Your PX360 Account Has Been Created")
else: else:
reset_token = PasswordResetTokenService.create_reset_token(user)
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
html_message = render_to_string( html_message = render_to_string(
"config/emails/reset_password_email.html", "config/emails/reset_password_email.html",
{"user": user, "reset_url": reset_url}, {"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"Use the link below to set your password:\n{reset_url}\n\n"
f"This link expires in 24 hours." f"This link expires in 24 hours."
) )
subject = _("Your PX360 Account Has Been Created")
NotificationService.send_email( NotificationService.send_email(
email=user.email, email=user.email,
@ -270,8 +287,8 @@ def user_create(request):
user=user, user=user,
notification_type="system", notification_type="system",
) )
if generated_password: if password_was_set:
messages.info(request, _("Credentials sent to {}.").format(user.email)) messages.info(request, _("Welcome email sent to {}.").format(user.email))
else: else:
messages.info(request, _("Password setup link sent to {}.").format(user.email)) messages.info(request, _("Password setup link sent to {}.").format(user.email))
except Exception as e: except Exception as e:

View File

@ -363,7 +363,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving an appreciation.""" """Read-only serializer for retrieving an appreciation."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True) hospital_name = serializers.CharField(source="hospital.name", read_only=True)
reference_number = serializers.SerializerMethodField()
class Meta: class Meta:
model = Appreciation model = Appreciation
@ -379,11 +378,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
] ]
read_only_fields = fields 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) # Suggestion (Feedback)
@ -433,7 +427,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
"""Read-only serializer for retrieving a suggestion.""" """Read-only serializer for retrieving a suggestion."""
hospital_name = serializers.CharField(source="hospital.name", read_only=True) hospital_name = serializers.CharField(source="hospital.name", read_only=True)
reference_number = serializers.SerializerMethodField()
class Meta: class Meta:
model = Feedback model = Feedback
@ -455,11 +448,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
] ]
read_only_fields = fields 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): class ExternalComplaintSatisfactionSerializer(serializers.Serializer):
satisfaction = serializers.ChoiceField( satisfaction = serializers.ChoiceField(

View File

@ -155,7 +155,7 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
status=status.HTTP_403_FORBIDDEN, 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( complaint = Complaint.objects.create(
patient=None, patient=None,
@ -187,6 +187,8 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
message="Complaint submitted via external API.", message="Complaint submitted via external API.",
) )
reference_number = complaint.reference_number
# Trigger background tasks # Trigger background tasks
try: try:
from apps.complaints.tasks import analyze_complaint_with_ai, notify_staff_new_item 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, 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( appreciation = Appreciation.objects.create(
hospital=data["hospital"], hospital=data["hospital"],
message_en=data["message"], message_en=data["message"],
@ -613,7 +608,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
visibility=AppreciationVisibility.PUBLIC, visibility=AppreciationVisibility.PUBLIC,
metadata={ metadata={
"source": "external_api", "source": "external_api",
"reference_number": reference_number,
"submitted_by_name": data["contact_name"], "submitted_by_name": data["contact_name"],
"submitted_by_phone": data["contact_phone"], "submitted_by_phone": data["contact_phone"],
}, },
@ -636,7 +630,7 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
return Response( return Response(
{ {
"success": True, "success": True,
"reference_number": reference_number, "reference_number": appreciation.reference_number,
"status": appreciation.status, "status": appreciation.status,
}, },
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
@ -646,22 +640,22 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView): class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView):
""" """
GET /api/v1/external/appreciations/ GET /api/v1/external/appreciations/
GET /api/v1/external/appreciations/<id>/ GET /api/v1/external/appreciations/<reference_number>/
""" """
entity_name = "appreciations" entity_name = "appreciations"
def get(self, request, pk=None): def get(self, request, reference_number=None):
access_err = self.check_entity_access() access_err = self.check_entity_access()
if access_err: if access_err:
return access_err return access_err
if pk: if reference_number:
return self._retrieve(request, pk) return self._retrieve(request, reference_number)
return self._list(request) return self._list(request)
def _retrieve(self, request, pk): def _retrieve(self, request, reference_number):
qs = Appreciation.all_objects.filter(pk=pk) qs = Appreciation.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs) qs = self.filter_queryset_by_hospital(qs)
appreciation = qs.first() appreciation = qs.first()
if not appreciation: if not appreciation:
@ -734,13 +728,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
"other": FeedbackCategory.OTHER, "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] title = data.get("title") or data["message"][:100]
feedback = Feedback.objects.create( feedback = Feedback.objects.create(
@ -756,7 +743,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
status=FeedbackStatus.SUBMITTED, status=FeedbackStatus.SUBMITTED,
metadata={ metadata={
"source": "external_api", "source": "external_api",
"reference_number": reference_number,
"suggestion_area": data.get("category", "general"), "suggestion_area": data.get("category", "general"),
}, },
) )
@ -780,7 +766,7 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
return Response( return Response(
{ {
"success": True, "success": True,
"reference_number": reference_number, "reference_number": feedback.reference_number,
"status": feedback.status, "status": feedback.status,
}, },
status=status.HTTP_201_CREATED, status=status.HTTP_201_CREATED,
@ -790,22 +776,22 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView): class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView):
""" """
GET /api/v1/external/suggestions/ GET /api/v1/external/suggestions/
GET /api/v1/external/suggestions/<id>/ GET /api/v1/external/suggestions/<reference_number>/
""" """
entity_name = "suggestions" entity_name = "suggestions"
def get(self, request, pk=None): def get(self, request, reference_number=None):
access_err = self.check_entity_access() access_err = self.check_entity_access()
if access_err: if access_err:
return access_err return access_err
if pk: if reference_number:
return self._retrieve(request, pk) return self._retrieve(request, reference_number)
return self._list(request) return self._list(request)
def _retrieve(self, request, pk): def _retrieve(self, request, reference_number):
qs = Feedback.all_objects.filter(pk=pk) qs = Feedback.all_objects.filter(reference_number=reference_number)
qs = self.filter_queryset_by_hospital(qs) qs = self.filter_queryset_by_hospital(qs)
feedback = qs.first() feedback = qs.first()
if not feedback: if not feedback:

View File

@ -119,7 +119,7 @@ urlpatterns = [
name="external-appreciation-list", name="external-appreciation-list",
), ),
path( path(
"appreciations/<uuid:pk>/", "appreciations/<str:reference_number>/",
ExternalAppreciationRetrieveView.as_view(), ExternalAppreciationRetrieveView.as_view(),
name="external-appreciation-detail", name="external-appreciation-detail",
), ),
@ -135,7 +135,7 @@ urlpatterns = [
name="external-suggestion-list", name="external-suggestion-list",
), ),
path( path(
"suggestions/<uuid:pk>/", "suggestions/<str:reference_number>/",
ExternalSuggestionRetrieveView.as_view(), ExternalSuggestionRetrieveView.as_view(),
name="external-suggestion-detail", name="external-suggestion-detail",
), ),

View File

@ -312,6 +312,18 @@ class NotificationService:
Returns: Returns:
NotificationLog instance 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 = "<p>" + message.replace("\n\n", "</p><p>").replace("\n", "<br>\n") + "</p>"
html_message = _render_to_string(
"emails/simple_email.html",
{"subject": subject, "content_html": content_html},
)
from apps.accounts.models import User from apps.accounts.models import User
# Check if Email API is enabled and use it (simulator or external API) # Check if Email API is enabled and use it (simulator or external API)

View File

@ -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("<!DOCTYPE", html) # full HTML document shell
# Plain-text content was converted to HTML paragraphs
self.assertIn("Line one.", html)
self.assertIn("<br>", html) # single newline -> <br>
self.assertIn("<p>", 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 = "<div>My custom layout</div>"
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("<!DOCTYPE", html)
self.assertIn("email-container", html)
def test_inquiry_dept_response_reminder_renders(self):
html = render_to_string("emails/inquiry_dept_response_reminder.html", {
"inquiry": type("Obj", (), {"reference_number": "INQ-001", "subject": "Test"})(),
"department_name": "Lab",
"hours_remaining": 12,
"sla_due_at": "2026-07-15T10:00:00",
"inquiry_url": "https://example.com/inquiries/1/",
})
self._assert_branded(html)
self.assertIn("INQ-001", html)
self.assertIn("Lab", html)
def test_observation_dept_response_reminder_renders(self):
html = render_to_string("emails/observation_dept_response_reminder.html", {
"observation": type("Obj", (), {"tracking_code": "OBS-001", "title": "Spill"})(),
"department_name": "Lab",
"hours_remaining": 8,
"sla_due_at": "2026-07-15T10:00:00",
"observation_url": "https://example.com/observations/1/",
})
self._assert_branded(html)
self.assertIn("OBS-001", html)
def test_inquiry_sla_reminder_renders(self):
html = render_to_string("emails/inquiry_sla_reminder.html", {
"inquiry": type("Obj", (), {
"reference_number": "INQ-002",
"subject": "Billing",
"due_at": "2026-07-15T10:00:00",
})(),
"hours_remaining": 24,
"inquiry_url": "https://example.com/inquiries/2/",
})
self._assert_branded(html)
self.assertIn("INQ-002", html)
def test_inquiry_sla_second_reminder_renders_urgent(self):
html = render_to_string("emails/inquiry_sla_second_reminder.html", {
"inquiry": type("Obj", (), {
"reference_number": "INQ-003",
"subject": "Billing",
"due_at": "2026-07-15T10:00:00",
})(),
"hours_remaining": 2,
"inquiry_url": "https://example.com/inquiries/3/",
})
self._assert_branded(html)
self.assertIn("URGENT", html)
self.assertIn("#dc2626", html) # red accent for urgency

View File

@ -11,6 +11,8 @@ import datetime
import logging import logging
from celery import shared_task from celery import shared_task
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -554,6 +556,14 @@ def send_observation_dept_response_reminders():
): ):
for recipient in recipients: for recipient in recipients:
try: try:
ctx = {
"observation": observation,
"department_name": dept.name,
"sla_due_at": observation.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"observation_url": f"{settings.SITE_URL.rstrip('/')}/observations/{observation.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email( NotificationService.send_email(
email=recipient.email, email=recipient.email,
subject=f"Reminder: Observation {observation.tracking_code} - Response Required", subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
@ -563,6 +573,7 @@ def send_observation_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. " f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response before the deadline." f"Please submit your response before the deadline."
), ),
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
related_object=observation, related_object=observation,
) )
except Exception as e: except Exception as e:
@ -581,6 +592,14 @@ def send_observation_dept_response_reminders():
): ):
for recipient in recipients: for recipient in recipients:
try: try:
ctx = {
"observation": observation,
"department_name": dept.name,
"sla_due_at": observation.dept_response_sla_due_at,
"hours_remaining": max(0, round(hours_remaining)),
"observation_url": f"{settings.SITE_URL.rstrip('/')}/observations/{observation.pk}/",
"recipient_name": recipient.get_full_name(),
}
NotificationService.send_email( NotificationService.send_email(
email=recipient.email, email=recipient.email,
subject=f"URGENT: Observation {observation.tracking_code} - Response Overdue Soon", subject=f"URGENT: Observation {observation.tracking_code} - Response Overdue Soon",
@ -590,6 +609,7 @@ def send_observation_dept_response_reminders():
f"Time remaining: {max(0, round(hours_remaining))} hours. " f"Time remaining: {max(0, round(hours_remaining))} hours. "
f"Please submit your response immediately." f"Please submit your response immediately."
), ),
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
related_object=observation, related_object=observation,
) )
except Exception as e: except Exception as e:

View File

@ -161,6 +161,70 @@ class StaffService:
return user, True, None # New user was created with no emailed password return user, True, None # New user was created with no emailed password
@staticmethod
def create_staff_for_user(user, staff_type='other', job_title='', request=None):
"""
Create a Staff record from an existing User.
Args:
user: User instance (must have first_name, last_name, hospital, employee_id)
staff_type: Staff type (physician|nurse|admin|other)
job_title: Job title string
request: HTTP request for audit logging
Returns:
tuple: (Staff instance, was_created: bool)
Raises:
ValueError: If user already has a staff profile, lacks hospital,
or employee_id is already taken by another staff member.
"""
from apps.organizations.models import Staff
if hasattr(user, 'staff_profile') and user.staff_profile:
raise ValueError("User already has a staff record")
if not user.hospital:
raise ValueError("User must have a hospital assigned before creating a staff record")
if not user.employee_id:
raise ValueError("Employee ID is required to create a staff record")
if Staff.objects.filter(employee_id=user.employee_id).exists():
raise ValueError(
f"Employee ID '{user.employee_id}' is already in use by another staff member"
)
staff = Staff.objects.create(
user=user,
first_name=user.first_name,
last_name=user.last_name,
email=user.email,
phone=user.phone or '',
employee_id=user.employee_id,
hospital=user.hospital,
department=user.department,
staff_type=staff_type or 'other',
job_title=job_title or '',
status='active',
)
if request:
AuditService.log_from_request(
event_type='other',
description=f"Staff record created for user {user.email}",
request=request,
content_object=staff,
metadata={
'user_id': str(user.id),
'staff_id': str(staff.id),
'staff_name': staff.get_full_name(),
'action': 'created_staff_from_user',
}
)
return staff, True
@staticmethod @staticmethod
def link_user_to_staff(staff, user_id, request=None): def link_user_to_staff(staff, user_id, request=None):
""" """

View File

@ -1814,7 +1814,7 @@ def department_list(request):
queryset = queryset.filter(id__in=directed_depts) queryset = queryset.filter(id__in=directed_depts)
else: else:
queryset = queryset.none() queryset = queryset.none()
elif user.is_champion() and user.department: elif user.is_champion() and user.department and not user.is_px_employee() and not user.is_px_management():
queryset = queryset.filter(id=user.department.id) queryset = queryset.filter(id=user.department.id)
elif user.is_basic_staff() and user.department: elif user.is_basic_staff() and user.department:
queryset = queryset.filter(id=user.department.id) queryset = queryset.filter(id=user.department.id)
@ -2055,12 +2055,14 @@ def department_detail(request, pk):
).first() ).first()
if not explanation and department.champion: if not explanation and department.champion:
import secrets as _secrets import secrets as _secrets
explanation = ComplaintExplanation.objects.create( explanation, _created = ComplaintExplanation.objects.get_or_create(
complaint=pc.complaint, complaint=pc.complaint,
staff=department.champion, staff=department.champion,
token=_secrets.token_urlsafe(32), defaults={
explanation="", "token": _secrets.token_urlsafe(32),
is_used=False, "explanation": "",
"is_used": False,
},
) )
if explanation: if explanation:
if explanation.is_used and needs_resubmit: if explanation.is_used and needs_resubmit:
@ -2117,11 +2119,17 @@ def department_detail(request, pk):
"department_name": mr.department.name, "department_name": mr.department.name,
}) })
# 2. Complaint Explanations (staff-level) # 2. Complaint Explanations (staff-level) — only for THIS department's staff
# and only for complaints not already covered by a CID-based entry above.
already_pending_complaint_ids = set(
str(a.get("complaint_id")) for a in pending_actions if a.get("complaint_id")
)
pending_explanations = ComplaintExplanation.objects.filter( pending_explanations = ComplaintExplanation.objects.filter(
complaint__department=department, staff__department=department,
is_used=False, is_used=False,
).select_related("complaint", "staff").order_by("sla_due_at") ).exclude(complaint_id__in=already_pending_complaint_ids).select_related(
"complaint", "staff"
).order_by("sla_due_at")
for pe in pending_explanations: for pe in pending_explanations:
pending_actions.append({ pending_actions.append({
"type": "complaint_explanation", "type": "complaint_explanation",

View File

@ -581,7 +581,7 @@
"name": "Created", "name": "Created",
"status": "Created", "status": "Created",
"code": 201, "code": 201,
"body": "{\n \"success\": true,\n \"reference_number\": \"APR-20260609-239270\",\n \"status\": \"draft\"\n}" "body": "{\n \"success\": true,\n \"reference_number\": \"APR-202607-0001\",\n \"status\": \"draft\"\n}"
} }
] ]
}, },
@ -626,9 +626,9 @@
} }
], ],
"url": { "url": {
"raw": "{{base_url}}/api/v1/external/appreciations/<uuid>/", "raw": "{{base_url}}/api/v1/external/appreciations/<reference_number>/",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
"path": ["api", "v1", "external", "appreciations", "<uuid>", ""] "path": ["api", "v1", "external", "appreciations", "<reference_number>", ""]
} }
}, },
"response": [] "response": []
@ -669,7 +669,7 @@
"name": "Created", "name": "Created",
"status": "Created", "status": "Created",
"code": 201, "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": { "url": {
"raw": "{{base_url}}/api/v1/external/suggestions/<uuid>/", "raw": "{{base_url}}/api/v1/external/suggestions/<reference_number>/",
"host": ["{{base_url}}"], "host": ["{{base_url}}"],
"path": ["api", "v1", "external", "suggestions", "<uuid>", ""] "path": ["api", "v1", "external", "suggestions", "<reference_number>", ""]
} }
}, },
"response": [] "response": []

View File

@ -452,7 +452,7 @@ POST /api/v1/external/appreciations/
```json ```json
{ {
"success": true, "success": true,
"reference_number": "APR-20260609-239270", "reference_number": "APR-202607-0001",
"status": "draft" "status": "draft"
} }
``` ```
@ -461,7 +461,7 @@ POST /api/v1/external/appreciations/
``` ```
GET /api/v1/external/appreciations/list/ GET /api/v1/external/appreciations/list/
GET /api/v1/external/appreciations/<uuid>/ GET /api/v1/external/appreciations/<reference_number>/
``` ```
**Retrieve Response:** **Retrieve Response:**
@ -469,7 +469,7 @@ GET /api/v1/external/appreciations/<uuid>/
```json ```json
{ {
"id": "uuid", "id": "uuid",
"reference_number": "APR-20260609-239270", "reference_number": "APR-202607-0001",
"message_en": "Dr. Ahmed was incredibly kind and thorough.", "message_en": "Dr. Ahmed was incredibly kind and thorough.",
"status": "draft", "status": "draft",
"hospital_name": "Al Nuzha", "hospital_name": "Al Nuzha",
@ -506,7 +506,7 @@ POST /api/v1/external/suggestions/
```json ```json
{ {
"success": true, "success": true,
"reference_number": "SG-20260609-316316", "reference_number": "SGT-202607-0001",
"status": "submitted" "status": "submitted"
} }
``` ```
@ -515,7 +515,7 @@ POST /api/v1/external/suggestions/
``` ```
GET /api/v1/external/suggestions/list/ GET /api/v1/external/suggestions/list/
GET /api/v1/external/suggestions/<uuid>/ GET /api/v1/external/suggestions/<reference_number>/
``` ```
**Retrieve Response:** **Retrieve Response:**
@ -523,7 +523,7 @@ GET /api/v1/external/suggestions/<uuid>/
```json ```json
{ {
"id": "uuid", "id": "uuid",
"reference_number": "SG-20260609-316316", "reference_number": "SGT-202607-0001",
"title": "Digital queue system", "title": "Digital queue system",
"message": "Please implement a digital queue management system.", "message": "Please implement a digital queue management system.",
"category": "technology", "category": "technology",
@ -669,5 +669,5 @@ All list endpoints return paginated results:
| Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` | | Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` |
| Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` | | Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` |
| Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) | | Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) |
| Appreciation | `APR-` | `APR-YYYYMMDD-XXXXXX` | | Appreciation | `APR-` | `APR-YYYYMM-NNNN` |
| Suggestion | `SG-` | `SG-YYYYMMDD-XXXXXX` | | Suggestion | `SGT-` | `SGT-YYYYMM-NNNN` |

View File

@ -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`. **(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-<random>` (`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/<reference_number>/`. Fires `notify_staff_new_item`.
**(d) REST API create** (`views.py:143`, serializer `serializers.py:132`): **(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`). 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`).

View File

@ -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) ### 2.3 Channel C — External API (machine-to-machine)
- View: `ExternalSuggestionCreateView` (`apps/integrations/api_views.py:700`), `POST /api/v1/external/suggestions/`. - 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/<reference_number>/`. Same post-save tasks (`api_views.py:765`).
### 2.4 Channel D — PX Source-User Portal ### 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`. - 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 ## 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. 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()`. 3. **`FeedbackForm` unused for creation** — `feedback_create` builds manually, bypassing form `clean()`.
4. **Transitions unenforced at DB/model/admin** — only `feedback_change_status` enforces. 4. **Transitions unenforced at DB/model/admin** — only `feedback_change_status` enforces.
5. **No outbound communication to suggester** by design. 5. **No outbound communication to suggester** by design.

View File

@ -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"> 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">
<i data-lucide="printer" class="w-4 h-4"></i> {% trans "PDF View" %} <i data-lucide="printer" class="w-4 h-4"></i> {% trans "PDF View" %}
</a> {% endcomment %} </a> {% 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 %}
<button onclick="showResolveModal()" class="bg-navy text-white px-4 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-blue transition"> <button onclick="showResolveModal()" class="bg-navy text-white px-4 py-2 rounded-lg text-sm font-bold shadow-md hover:bg-blue transition">
{% trans "Resolve Case" %} {% trans "Resolve Case" %}
</button> </button>
@ -390,25 +390,25 @@
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details"> <button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">
{% trans "Details" %} {% trans "Details" %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments">
{% trans "Departments" %} ({{ complaint.involved_departments_count }}) {% trans "Departments" %} ({{ complaint.involved_departments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
{% trans "Timeline" %} ({{ stage_timeline.stages|length }}) {% trans "Timeline" %} ({{ stage_timeline.stages|length }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> </button>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}" {% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %}"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments">
{% trans "Attachments" %} ({{ complaint.attachments_count }}) {% trans "Attachments" %} ({{ complaint.attachments_count }})
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3 inline ml-1"></i>{% endif %}
</button> {% endcomment %} </button> {% endcomment %}
{% if can_admin %} {% if can_admin %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('actions')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-actions">
<i data-lucide="list-checks" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> <i data-lucide="list-checks" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "PX Actions" %} {% trans "PX Actions" %}
{% if px_actions.count %} {% if px_actions.count %}
<span class="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">{{ px_actions.count }}</span> <span class="ml-1 px-1.5 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">{{ px_actions.count }}</span>
@ -416,36 +416,36 @@
{% if linked_rcas %} {% if linked_rcas %}
<span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">{{ linked_rcas.count }}</span> <span class="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full">{{ linked_rcas.count }}</span>
{% endif %} {% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
{% endif %} {% endif %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('ai')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-ai">
<i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %} <i data-lucide="sparkles" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('resolution')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-resolution">
<i data-lucide="check-circle-2" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> <i data-lucide="check-circle-2" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "Resolution" %} {% trans "Resolution" %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
{% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1" {% comment %} <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-1"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('adverse')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-adverse">
<i data-lucide="shield-alert" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> <i data-lucide="shield-alert" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
{% trans "Adverse Actions" %} {% trans "Adverse Actions" %}
{% if adverse_actions %} {% if adverse_actions %}
<span class="ml-1 px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded-full">{{ adverse_actions.count }}</span> <span class="ml-1 px-1.5 py-0.5 bg-red-100 text-red-700 text-xs rounded-full">{{ adverse_actions.count }}</span>
{% endif %} {% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> {% endcomment %} </button> {% endcomment %}
<button class="py-4 text-sm {% if complaint.assigned_to == current_user or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2" <button class="py-4 text-sm {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}tab-inactive{% else %}text-slate-300 cursor-not-allowed{% endif %} flex items-center gap-2"
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes"> {% if complaint.assigned_to == current_user or can_admin or not complaint.is_active_status %}onclick="switchTab('notes')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-notes">
<i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %} <i data-lucide="message-square" class="w-4 h-4 {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %}
{% if notes_count %} {% if notes_count %}
<span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ notes_count }}</span> <span class="ml-1 px-1.5 py-0.5 bg-slate-100 text-slate-600 text-xs rounded-full">{{ notes_count }}</span>
{% endif %} {% endif %}
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %} {% if not complaint.assigned_to == current_user and not can_admin and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% endif %}
</button> </button>
</nav> </nav>
@ -598,7 +598,7 @@
<span class="text-sm font-bold text-yellow-700">{% trans "Pending Approval" %}</span> <span class="text-sm font-bold text-yellow-700">{% trans "Pending Approval" %}</span>
</div> </div>
<p class="text-xs text-yellow-600 mb-3">{% trans "OVR escalation requested. Please review and approve or reject." %}</p> <p class="text-xs text-yellow-600 mb-3">{% trans "OVR escalation requested. Please review and approve or reject." %}</p>
{% if request.user.is_px_admin or request.user.is_hospital_admin or request.user.is_px_management %} {% if request.user.is_px_admin or request.user.is_hospital_admin or complaint.assigned_to == current_user %}
<div class="flex gap-2"> <div class="flex gap-2">
<form method="post" action="{% url 'complaints:approve_ovr_escalation' pk=complaint.pk %}"> <form method="post" action="{% url 'complaints:approve_ovr_escalation' pk=complaint.pk %}">
{% csrf_token %} {% csrf_token %}
@ -834,10 +834,10 @@
{% csrf_token %} {% csrf_token %}
<button type="submit" <button type="submit"
class="p-3 border-blue-200 bg-blue-50 rounded-xl hover:bg-blue-100 flex flex-col items-center gap-2 group transition col-span-2 mb-2" class="p-3 border-blue-200 bg-blue-50 rounded-xl hover:bg-blue-100 flex flex-col items-center gap-2 group transition col-span-2 mb-2"
{% if complaint.assigned_to == current_user %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}> {% if complaint.activated_at %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
<i data-lucide="{% if complaint.assigned_to == current_user %}check-circle{% else %}play-circle{% endif %}" class="w-5 h-5 text-blue"></i> <i data-lucide="{% if complaint.activated_at %}check-circle{% else %}play-circle{% endif %}" class="w-5 h-5 text-blue"></i>
<span class="text-[10px] font-bold text-blue uppercase"> <span class="text-[10px] font-bold text-blue uppercase">
{% if complaint.assigned_to == current_user %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %} {% if complaint.activated_at %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %}
</span> </span>
</button> </button>
</form> </form>
@ -876,6 +876,12 @@
</span> </span>
</button> </button>
</form> </form>
{% if complaint.sent_to_department and not workflow_steps.department_responded %}
<button onclick="showReminderModal()" class="p-3 border rounded-xl hover:bg-light flex flex-col items-center gap-2 group transition">
<i data-lucide="bell" class="w-5 h-5 text-slate group-hover:text-amber-500"></i>
<span class="text-[10px] font-bold uppercase">{% trans "Remind" %}</span>
</button>
{% endif %}
{% endif %} {% endif %}
{% else %} {% else %}
<!-- Not yet activated: only Activate / Assign / Close are available --> <!-- Not yet activated: only Activate / Assign / Close are available -->
@ -1251,6 +1257,43 @@
</div> </div>
</div> </div>
<!-- Department Reminder Modal -->
<div id="reminderModal" 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-md mx-4">
<div class="flex items-center justify-between p-5 border-b border-slate-100">
<h3 class="text-lg font-bold text-navy flex items-center gap-2"><i data-lucide="bell" class="w-5 h-5"></i> {% trans "Send Reminder" %}</h3>
<button type="button" onclick="closeModal('reminderModal')" class="p-1.5 rounded-lg hover:bg-slate-100 text-slate-400"><i data-lucide="x" class="w-5 h-5"></i></button>
</div>
<form method="post" action="{% url 'complaints:complaint_send_dept_reminder' pk=complaint.pk %}" class="p-5 space-y-4">
{% csrf_token %}
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Select Department" %} <span class="text-red-500">*</span></label>
<select name="involved_department_id" required
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none">
<option value="">{% trans "Select Department" %}</option>
{% for inv in complaint.involved_departments.all %}
{% if inv.sent and not inv.response_submitted %}
<option value="{{ inv.pk }}">{{ inv.department.get_localized_name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 mb-1.5">{% trans "Note (optional)" %}</label>
<textarea name="note" rows="2"
class="w-full border border-slate-200 rounded-xl p-3 text-sm focus:ring-2 focus:ring-navy/20 outline-none"
placeholder="{% trans 'Add context or instructions...' %}"></textarea>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeModal('reminderModal')" class="flex-1 px-4 py-2.5 border border-slate-200 text-slate rounded-xl font-semibold hover:bg-slate-50 transition">{% trans "Cancel" %}</button>
<button type="submit" class="flex-1 px-4 py-2.5 bg-amber-500 text-white rounded-xl font-bold hover:bg-amber-600 transition flex items-center justify-center gap-2">
<i data-lucide="bell" class="w-4 h-4"></i> {% trans "Send Reminder" %}
</button>
</div>
</form>
</div>
</div>
<!-- Taxonomy Edit Modal --> <!-- Taxonomy Edit Modal -->
<div id="taxonomyModal" style="display:none" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"> <div id="taxonomyModal" 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 p-6 w-full max-w-lg shadow-2xl"> <div class="bg-white rounded-2xl p-6 w-full max-w-lg shadow-2xl">
@ -1371,7 +1414,7 @@ function closeModal(modalId) {
// Close modal when clicking outside the dialog // Close modal when clicking outside the dialog
window.onclick = function(event) { window.onclick = function(event) {
if (event && event.target) { if (event && event.target) {
var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'sendToDeptModal']; var modals = ['resolveModal', 'assignModal', 'followUpModal', 'escalateModal', 'closeModal', 'locationModal', 'sendToDeptModal', 'reminderModal'];
if (modals.indexOf(event.target.id) !== -1) { if (modals.indexOf(event.target.id) !== -1) {
event.target.style.display = 'none'; event.target.style.display = 'none';
} }
@ -1544,6 +1587,11 @@ function saveTaxonomy() {
} }
// ─── Send to Department modal (department + staff selects) ─── // ─── Send to Department modal (department + staff selects) ───
function showReminderModal() {
var m = document.getElementById('reminderModal');
if (m) m.style.display = 'flex';
}
function openSendToDeptModal() { function openSendToDeptModal() {
var m = document.getElementById('sendToDeptModal'); var m = document.getElementById('sendToDeptModal');
if (!m) return; if (!m) return;

View File

@ -2,7 +2,7 @@
{% load i18n %} {% load i18n %}
{% block title %}{% trans "Your PX360 Account Has Been Created - Al Hammadi Hospital" %}{% endblock %} {% block title %}{% trans "Your PX360 Account Has Been Created - Al Hammadi Hospital" %}{% endblock %}
{% block preheader %}{% trans "Your account is ready. Use your temporary password to log in." %}{% endblock %} {% block preheader %}{% trans "Your account is ready." %}{% endblock %}
{% block content %} {% block content %}
<p style="margin: 0 0 15px;"> <p style="margin: 0 0 15px;">
@ -10,20 +10,23 @@
</p> </p>
<p style="margin: 0 0 20px;"> <p style="margin: 0 0 20px;">
{% trans "An account has been created for you on PX360. You can log in using the credentials below." %} {% trans "An account has been created for you on PX360." %}
</p> </p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 0 20px; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 0 20px; background-color: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0;">
<tr> <tr>
<td style="padding: 20px;"> <td style="padding: 20px;">
<p style="margin: 0 0 8px;"><strong>{% trans "Email:" %}</strong> {{ user.email }}</p> <p style="margin: 0 0 8px;"><strong>{% trans "Email:" %}</strong> {{ user.email }}</p>
{% if temp_password %}
<p style="margin: 0 0 8px;"><strong>{% trans "Temporary Password:" %}</strong> <code style="background-color: #fee2e2; padding: 2px 8px; border-radius: 4px; font-size: 14px; color: #dc2626;">{{ temp_password }}</code></p> <p style="margin: 0 0 8px;"><strong>{% trans "Temporary Password:" %}</strong> <code style="background-color: #fee2e2; padding: 2px 8px; border-radius: 4px; font-size: 14px; color: #dc2626;">{{ temp_password }}</code></p>
{% endif %}
</td> </td>
</tr> </tr>
</table> </table>
{% if reset_url %}
<p style="margin: 0 0 25px;"> <p style="margin: 0 0 25px;">
<strong>{% trans "Important:" %}</strong> {% trans "For security, please change your password immediately after logging in." %} <strong>{% trans "Important:" %}</strong> {% trans "For security, please set your password using the link below." %}
</p> </p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
@ -36,6 +39,22 @@
</td> </td>
</tr> </tr>
</table> </table>
{% else %}
<p style="margin: 0 0 25px;">
{% trans "You can now log in using the credentials provided to you by your administrator." %}
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ login_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
{% trans "Log In" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 0; font-size: 14px; color: #666666;"> <p style="margin: 25px 0 0; font-size: 14px; color: #666666;">
{% trans "If you did not expect this account creation, please contact your system administrator immediately." %} {% trans "If you did not expect this account creation, please contact your system administrator immediately." %}

View File

@ -180,9 +180,41 @@
</div> </div>
</div> </div>
</section> </section>
{% endif %}
<!-- Personal Information --> <!-- Create Staff Record -->
<section class="form-section">
<h3 class="font-bold text-navy mb-6 text-lg flex items-center gap-2">
<i data-lucide="user-plus" class="w-5 h-5 text-blue"></i>
{% trans "Create Staff Record" %}
</h3>
<div class="flex items-start gap-3 p-4 bg-light/30 rounded-xl">
<input type="checkbox" name="create_staff" id="create_staff"
class="w-5 h-5 text-navy border-slate-300 rounded focus:ring-navy mt-0.5">
<div>
<label for="create_staff" class="text-sm font-semibold text-navy block cursor-pointer">{% trans "Also create a staff record" %}</label>
<p class="text-xs text-slate mt-1">{% trans "Creates a staff directory entry linked to this user. Employee ID is required." %}</p>
</div>
</div>
<div id="createStaffFields" class="hidden mt-4 grid grid-cols-2 gap-4">
<div>
<label for="staff_type" class="block text-sm font-semibold text-navy mb-2">{% trans "Staff Type" %} <span class="text-red-500">*</span></label>
<select name="staff_type" id="staff_type"
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">
<option value="other">{% trans "Other" %}</option>
<option value="physician">{% trans "Physician" %}</option>
<option value="nurse">{% trans "Nurse" %}</option>
<option value="admin">{% trans "Administrative" %}</option>
</select>
</div>
<div>
<label for="staff_job_title" class="block text-sm font-semibold text-navy mb-2">{% trans "Job Title" %} <span class="text-red-500">*</span></label>
<input type="text" name="staff_job_title" id="staff_job_title"
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="{% trans 'e.g. Registered Nurse' %}">
</div>
</div>
</section>
{% endif %}
<section class="form-section"> <section class="form-section">
<h3 class="font-bold text-navy mb-6 text-lg flex items-center gap-2"> <h3 class="font-bold text-navy mb-6 text-lg flex items-center gap-2">
<i data-lucide="user" class="w-5 h-5 text-blue"></i> <i data-lucide="user" class="w-5 h-5 text-blue"></i>
@ -358,7 +390,7 @@
<div class="mt-4 p-3 bg-blue-50 border border-blue-100 rounded-lg"> <div class="mt-4 p-3 bg-blue-50 border border-blue-100 rounded-lg">
<p class="text-blue-700 text-xs flex items-start gap-2"> <p class="text-blue-700 text-xs flex items-start gap-2">
<i data-lucide="info" class="w-4 h-4 flex-shrink-0 mt-0.5"></i> <i data-lucide="info" class="w-4 h-4 flex-shrink-0 mt-0.5"></i>
{% trans "Leave password blank and click Generate for a random password. The credentials will be sent to the user's email." %} {% trans "Set a password now, or leave blank to send a password setup link via email." %}
</p> </p>
</div> </div>
</section> </section>
@ -419,7 +451,7 @@
</li> </li>
<li class="flex items-start gap-2"> <li class="flex items-start gap-2">
<i data-lucide="check" class="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5"></i> <i data-lucide="check" class="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5"></i>
<span>{% trans "A password reset email will be sent automatically" %}</span> <span>{% trans "Set a password or leave blank to email a setup link" %}</span>
</li> </li>
</ul> </ul>
</section> </section>
@ -461,35 +493,63 @@ document.addEventListener('DOMContentLoaded', function() {
// ===== Department loading ===== // ===== Department loading =====
function loadDepartments(hospitalId, selectedDept) { function loadDepartments(hospitalId, selectedDept) {
if (!departmentSelect) return; if (!departmentSelect) return;
var ts = departmentSelect.tomselect;
var placeholderText = '{% trans "Select Department" %}';
if (!hospitalId) { if (!hospitalId) {
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>'; if (ts) {
ts.clear();
ts.clearOptions();
ts.addOption({value: '', text: placeholderText});
} else {
departmentSelect.innerHTML = '<option value="">' + placeholderText + '</option>';
}
return; return;
} }
departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>'; if (ts) { ts.clear(); ts.clearOptions(); ts.addOption({value: '', text: '{% trans "Loading..." %}'}); }
else { departmentSelect.innerHTML = '<option value="">{% trans "Loading..." %}</option>'; }
fetch('/organizations/api/departments/?hospital=' + hospitalId) fetch('/organizations/api/departments/?hospital=' + hospitalId)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const results = data.results || data; const results = data.results || data;
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
results.forEach(dept => { if (ts) {
const option = document.createElement('option'); ts.clear();
option.value = dept.id; ts.clearOptions();
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %}; ts.addOption({value: '', text: placeholderText});
departmentSelect.appendChild(option); results.forEach(dept => {
}); ts.addOption({
value: dept.id,
text: {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %}
});
});
} else {
departmentSelect.innerHTML = '<option value="">' + placeholderText + '</option>';
results.forEach(dept => {
const option = document.createElement('option');
option.value = dept.id;
option.textContent = {% if LANG == 'ar' %}dept.name_ar || dept.name{% else %}dept.name{% endif %};
departmentSelect.appendChild(option);
});
}
if (selectedDept) { var selectVal = selectedDept || ('{{ form.department.value }}' || '');
departmentSelect.value = selectedDept; if (selectVal) {
} else if ('{{ form.department.value }}') { if (ts) { ts.setValue(selectVal, true); }
departmentSelect.value = '{{ form.department.value }}'; else { departmentSelect.value = selectVal; }
} }
}) })
.catch(error => { .catch(error => {
console.error('Error loading departments:', error); console.error('Error loading departments:', error);
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>'; if (ts) {
ts.clear();
ts.clearOptions();
ts.addOption({value: '', text: '{% trans "Error loading departments" %}'});
} else {
departmentSelect.innerHTML = '<option value="">{% trans "Error loading departments" %}</option>';
}
}); });
} }
@ -510,6 +570,38 @@ document.addEventListener('DOMContentLoaded', function() {
const selectedStaffName = document.getElementById('selectedStaffName'); const selectedStaffName = document.getElementById('selectedStaffName');
const staffSearchInputWrap = document.getElementById('staffSearchInput-wrap'); const staffSearchInputWrap = document.getElementById('staffSearchInput-wrap');
// ===== Create Staff Toggle =====
const createStaffCheckbox = document.getElementById('create_staff');
const createStaffFields = document.getElementById('createStaffFields');
const staffSearchSection = staffSearchInput ? staffSearchInput.closest('.form-section') : null;
if (createStaffCheckbox) {
createStaffCheckbox.addEventListener('change', function() {
if (this.checked) {
createStaffFields.classList.remove('hidden');
if (staffSearchSection) staffSearchSection.classList.add('hidden');
if (staffIdInput) staffIdInput.value = '';
} else {
createStaffFields.classList.add('hidden');
if (staffSearchSection) staffSearchSection.classList.remove('hidden');
}
lucide.createIcons();
});
}
function disableCreateStaff() {
if (createStaffCheckbox) {
createStaffCheckbox.checked = false;
createStaffFields.classList.add('hidden');
}
}
function enableCreateStaff() {
if (staffSearchSection && createStaffCheckbox && createStaffCheckbox.checked) {
staffSearchSection.classList.add('hidden');
}
}
if (staffSearchInput) { if (staffSearchInput) {
let debounceTimer; let debounceTimer;
@ -603,6 +695,8 @@ document.addEventListener('DOMContentLoaded', function() {
staffSearchInputWrap.classList.add('hidden'); staffSearchInputWrap.classList.add('hidden');
selectedStaffChip.classList.remove('hidden'); selectedStaffChip.classList.remove('hidden');
disableCreateStaff();
// Auto-fill fields // Auto-fill fields
const fields = { const fields = {
'id_first_name': data.first_name, 'id_first_name': data.first_name,
@ -636,8 +730,34 @@ document.addEventListener('DOMContentLoaded', function() {
staffSearchInputWrap.classList.remove('hidden'); staffSearchInputWrap.classList.remove('hidden');
staffSearchInput.value = ''; staffSearchInput.value = '';
staffSearchResults.classList.remove('active'); staffSearchResults.classList.remove('active');
if (createStaffCheckbox && staffSearchSection) {
staffSearchSection.classList.remove('hidden');
}
}; };
// ===== Form Validation =====
const userForm = document.getElementById('userForm');
if (userForm && createStaffCheckbox) {
userForm.addEventListener('submit', function(e) {
if (createStaffCheckbox.checked) {
var empId = document.getElementById('id_employee_id');
var jobTitle = document.getElementById('staff_job_title');
var staffType = document.getElementById('staff_type');
var missing = [];
if (!empId || !empId.value.trim()) missing.push('{% trans "Employee ID" %}');
if (!jobTitle || !jobTitle.value.trim()) missing.push('{% trans "Job Title" %}');
if (!staffType || !staffType.value) missing.push('{% trans "Staff Type" %}');
if (missing.length) {
e.preventDefault();
alert('{% trans "Please fill in the following required fields:" %} ' + missing.join(', '));
return false;
}
}
});
}
if (typeof lucide !== 'undefined') { if (typeof lucide !== 'undefined') {
lucide.createIcons(); lucide.createIcons();
} }

View File

@ -0,0 +1,40 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
{% block title %}{% trans "SLA Reminder - Inquiry" %}{% endblock %}
{% block preheader %}{% trans "An inquiry assigned to you is approaching its SLA deadline." %}{% endblock %}
{% block content %}
<p style="margin: 0 0 15px;">
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
</p>
<p style="margin: 0 0 20px;">
{% trans "This is an automated reminder that an inquiry assigned to you is approaching its SLA deadline. Please review and take appropriate action." %}
</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Reference:" %}</strong> {{ inquiry.reference_number }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Subject:" %}</strong> {{ inquiry.subject }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Due Date:" %}</strong> {{ inquiry.due_at|date:"F d, Y H:i" }}</p>
<p style="margin: 0 0 20px;"><strong>{% trans "Time Remaining:" %}</strong> {{ hours_remaining }} {% trans "hours" %}</p>
{% if inquiry_url %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ inquiry_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #005696;">
{% trans "View Inquiry" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 5px; font-size: 13px; color: #666666; direction: rtl; text-align: right;">
تذكير اتفاقية مستوى الخدمة - استفسار {{ inquiry.reference_number }}
</p>
<p style="margin: 0; font-size: 12px; color: #999999; direction: rtl; text-align: right; line-height: 1.8;">
تاريخ الاستحقاق: {{ inquiry.due_at|date:"Y F d H:i" }} - الوقت المتبقي: {{ hours_remaining }} ساعة
</p>
{% endblock %}

View File

@ -0,0 +1,40 @@
{% extends 'emails/base_email_template.html' %}
{% load i18n %}
{% block title %}{% trans "URGENT: SLA Reminder - Inquiry" %}{% endblock %}
{% block preheader %}{% trans "An inquiry assigned to you is about to breach its SLA deadline." %}{% endblock %}
{% block content %}
<p style="margin: 0 0 15px;">
{% trans "Dear" %} <strong>{{ recipient_name|default:'Colleague' }}</strong>,
</p>
<p style="margin: 0 0 20px; color: #dc2626; font-weight: bold;">
{% trans "URGENT: An inquiry assigned to you is about to breach its SLA deadline and requires your immediate attention." %}
</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Reference:" %}</strong> {{ inquiry.reference_number }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Subject:" %}</strong> {{ inquiry.subject }}</p>
<p style="margin: 0 0 5px;"><strong>{% trans "Due Date:" %}</strong> {{ inquiry.due_at|date:"F d, Y H:i" }}</p>
<p style="margin: 0 0 20px;"><strong>{% trans "Time Remaining:" %}</strong> {{ hours_remaining }} {% trans "hours" %}</p>
{% if inquiry_url %}
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td align="center">
<a href="{{ inquiry_url }}"
style="display: inline-block; padding: 12px 32px; font-size: 16px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 6px; background-color: #dc2626;">
{% trans "View Inquiry" %}
</a>
</td>
</tr>
</table>
{% endif %}
<p style="margin: 25px 0 5px; font-size: 13px; color: #666666; direction: rtl; text-align: right;">
عاجل: تذكير اتفاقية مستوى الخدمة - استفسار {{ inquiry.reference_number }}
</p>
<p style="margin: 0; font-size: 12px; color: #999999; direction: rtl; text-align: right; line-height: 1.8;">
تاريخ الاستحقاق: {{ inquiry.due_at|date:"Y F d H:i" }} - الوقت المتبقي: {{ hours_remaining }} ساعة
</p>
{% endblock %}

View File

@ -149,7 +149,7 @@
</a> </a>
<!-- Department Champion: only Dashboard + My Department --> <!-- Department Champion: only Dashboard + My Department -->
{% if user.is_champion and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager %} {% if user.is_champion and not user.is_px_admin and not user.is_hospital_admin and not user.is_department_manager and not user.is_px_employee and not user.is_px_management %}
{% if user.department %} {% if user.department %}
<a href="{% url 'organizations:department_detail' pk=user.department.pk %}" <a href="{% url 'organizations:department_detail' pk=user.department.pk %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}"> class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
@ -324,7 +324,7 @@
<!-- Departments --> <!-- Departments -->
{% if not user.is_basic_staff and not user.is_department_manager and not user.is_director %} {% if not user.is_basic_staff and not user.is_department_manager and not user.is_director %}
{% if not user.source_user_profile %} {% if not user.source_user_profile %}
{% if user.is_champion and user.department %} {% if user.is_champion and user.department and not user.is_px_employee and not user.is_px_management %}
<a href="{% url 'organizations:department_detail' pk=user.department.pk %}" <a href="{% url 'organizations:department_detail' pk=user.department.pk %}"
class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}"> class="flex items-center gap-3 p-3 rounded-lg transition {% if 'departments' in request.path %}nav-item-active{% else %}opacity-70 hover:opacity-100 hover:bg-white/10{% endif %}">
<i data-lucide="building-2" class="w-5 h-5 flex-shrink-0"></i> <i data-lucide="building-2" class="w-5 h-5 flex-shrink-0"></i>