update
This commit is contained in:
parent
6baa34dec3
commit
fe0a643607
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}).
|
||||||
|
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
129
apps/notifications/tests_email.py
Normal file
129
apps/notifications/tests_email.py
Normal 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
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": []
|
||||||
|
|||||||
@ -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` |
|
||||||
|
|||||||
@ -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`).
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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." %}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
40
templates/emails/inquiry_sla_reminder.html
Normal file
40
templates/emails/inquiry_sla_reminder.html
Normal 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 %}
|
||||||
40
templates/emails/inquiry_sla_second_reminder.html
Normal file
40
templates/emails/inquiry_sla_second_reminder.html
Normal 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 %}
|
||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user