update
This commit is contained in:
parent
6baa34dec3
commit
fe0a643607
@ -77,7 +77,7 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
|
||||
required=False,
|
||||
widget=forms.PasswordInput(attrs={
|
||||
"class": "w-full px-4 py-3 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-navy focus:border-transparent transition bg-white text-sm",
|
||||
"placeholder": _("Leave blank to generate random password"),
|
||||
"placeholder": _("Leave blank to send a password setup link via email"),
|
||||
}),
|
||||
)
|
||||
password_confirm = forms.CharField(
|
||||
@ -111,6 +111,8 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
|
||||
if self.user and self.user.is_hospital_admin() and self.user.hospital:
|
||||
self.fields["hospital"].initial = self.user.hospital
|
||||
self.fields["hospital"].queryset = Hospital.objects.filter(id=self.user.hospital.id)
|
||||
elif self.user and self.user.is_px_admin() and getattr(self.request, "tenant_hospital", None):
|
||||
self.fields["hospital"].initial = self.request.tenant_hospital
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data.get("email")
|
||||
@ -127,18 +129,14 @@ class UserCreateForm(DepartmentFieldMixin, forms.ModelForm):
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True):
|
||||
import secrets
|
||||
import string
|
||||
|
||||
user = super().save(commit=False)
|
||||
password = self.cleaned_data.get("password")
|
||||
|
||||
if not password:
|
||||
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
||||
password = ''.join(secrets.choice(alphabet) for _ in range(16))
|
||||
self._generated_password = password
|
||||
|
||||
if password:
|
||||
user.set_password(password)
|
||||
else:
|
||||
user.set_unusable_password()
|
||||
|
||||
if commit:
|
||||
user.save()
|
||||
selected_groups = self.cleaned_data.get("groups")
|
||||
|
||||
@ -81,14 +81,16 @@ class ComplaintService:
|
||||
return True
|
||||
if user.is_hospital_admin() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if complaint.assigned_to and complaint.assigned_to == user:
|
||||
return True
|
||||
# Before activation: PX staff can claim/activate the complaint
|
||||
if not complaint.activated_at:
|
||||
if user.is_px_management() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if user.is_px_employee() and user.hospital == complaint.hospital:
|
||||
return True
|
||||
if user.is_department_manager() and user.department == complaint.department:
|
||||
return True
|
||||
if complaint.assigned_to and complaint.assigned_to == user:
|
||||
return True
|
||||
if user.department_id and complaint.involved_departments.filter(department_id=user.department_id).exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -2948,10 +2948,17 @@ def send_inquiry_sla_reminders():
|
||||
if hours_since_creation >= first_reminder_hours:
|
||||
if inquiry.assigned_to and inquiry.assigned_to.email:
|
||||
try:
|
||||
ctx = {
|
||||
"inquiry": inquiry,
|
||||
"recipient_name": inquiry.assigned_to.get_full_name(),
|
||||
"hours_remaining": max(0, round(hours_until_due)),
|
||||
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
|
||||
}
|
||||
NotificationService.send_email(
|
||||
email=inquiry.assigned_to.email,
|
||||
subject=f"SLA Reminder - Inquiry #{inquiry.id}",
|
||||
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. Please take action.",
|
||||
html_message=render_to_string("emails/inquiry_sla_reminder.html", ctx),
|
||||
related_object=inquiry,
|
||||
)
|
||||
inquiry.reminder_sent_at = now
|
||||
@ -2965,10 +2972,17 @@ def send_inquiry_sla_reminders():
|
||||
if hours_since_creation >= second_reminder_hours:
|
||||
if inquiry.assigned_to and inquiry.assigned_to.email:
|
||||
try:
|
||||
ctx = {
|
||||
"inquiry": inquiry,
|
||||
"recipient_name": inquiry.assigned_to.get_full_name(),
|
||||
"hours_remaining": max(0, round(hours_until_due)),
|
||||
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
|
||||
}
|
||||
NotificationService.send_email(
|
||||
email=inquiry.assigned_to.email,
|
||||
subject=f"URGENT: SLA Reminder - Inquiry #{inquiry.id}",
|
||||
message=f"Inquiry '{inquiry.subject}' is due at {inquiry.due_at}. URGENT action required.",
|
||||
html_message=render_to_string("emails/inquiry_sla_second_reminder.html", ctx),
|
||||
related_object=inquiry,
|
||||
)
|
||||
inquiry.second_reminder_sent_at = now
|
||||
@ -3233,6 +3247,14 @@ def send_inquiry_dept_response_reminders():
|
||||
):
|
||||
for recipient in recipients:
|
||||
try:
|
||||
ctx = {
|
||||
"inquiry": inquiry,
|
||||
"department_name": dept.name,
|
||||
"sla_due_at": inquiry.dept_response_sla_due_at,
|
||||
"hours_remaining": max(0, round(hours_remaining)),
|
||||
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
|
||||
"recipient_name": recipient.get_full_name(),
|
||||
}
|
||||
NotificationService.send_email(
|
||||
email=recipient.email,
|
||||
subject=f"Reminder: Inquiry #{inquiry.reference_number} - Response Required",
|
||||
@ -3242,6 +3264,7 @@ def send_inquiry_dept_response_reminders():
|
||||
f"Time remaining: {max(0, round(hours_remaining))} hours. "
|
||||
f"Please submit your response before the deadline."
|
||||
),
|
||||
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
|
||||
related_object=inquiry,
|
||||
)
|
||||
except Exception as e:
|
||||
@ -3260,6 +3283,14 @@ def send_inquiry_dept_response_reminders():
|
||||
):
|
||||
for recipient in recipients:
|
||||
try:
|
||||
ctx = {
|
||||
"inquiry": inquiry,
|
||||
"department_name": dept.name,
|
||||
"sla_due_at": inquiry.dept_response_sla_due_at,
|
||||
"hours_remaining": max(0, round(hours_remaining)),
|
||||
"inquiry_url": f"{settings.SITE_URL.rstrip('/')}/inquiries/{inquiry.pk}/",
|
||||
"recipient_name": recipient.get_full_name(),
|
||||
}
|
||||
NotificationService.send_email(
|
||||
email=recipient.email,
|
||||
subject=f"URGENT: Inquiry #{inquiry.reference_number} - Response Overdue Soon",
|
||||
@ -3269,6 +3300,7 @@ def send_inquiry_dept_response_reminders():
|
||||
f"Time remaining: {max(0, round(hours_remaining))} hours. "
|
||||
f"Please submit your response immediately."
|
||||
),
|
||||
html_message=render_to_string("emails/inquiry_dept_response_reminder.html", ctx),
|
||||
related_object=inquiry,
|
||||
)
|
||||
except Exception as e:
|
||||
@ -3534,7 +3566,7 @@ def notify_champion_on_dept_assignment_task(involved_department_id):
|
||||
department_url = f"{base}/organizations/departments/{department.pk}/"
|
||||
|
||||
NotificationService.send_email(
|
||||
recipient=champion_user.email,
|
||||
email=champion_user.email,
|
||||
subject=f"New Complaint Assigned - {complaint.reference_number}",
|
||||
message=f"""A new complaint has been assigned to your department ({department.name}).
|
||||
|
||||
|
||||
@ -612,6 +612,28 @@ def complaint_detail(request, pk):
|
||||
|
||||
ComplaintService.ensure_involved_records(complaint)
|
||||
|
||||
# Self-heal: if the complaint was sent to its primary department but no CID
|
||||
# row exists (legacy data from before the fix), create one now so the primary
|
||||
# department appears in the Involved Departments list.
|
||||
if complaint.sent_to_department and complaint.department_id:
|
||||
if not any(inv.department_id == complaint.department_id
|
||||
for inv in complaint.involved_departments.all()):
|
||||
from .models import ComplaintInvolvedDepartment
|
||||
ComplaintInvolvedDepartment.objects.get_or_create(
|
||||
complaint=complaint,
|
||||
department=complaint.department,
|
||||
defaults={
|
||||
"role": "primary",
|
||||
"is_primary": True,
|
||||
"added_by": request.user,
|
||||
"forwarded_at": complaint.forwarded_to_dept_at or timezone.now(),
|
||||
"sent": True,
|
||||
"sent_at": complaint.sent_to_department_at or timezone.now(),
|
||||
},
|
||||
)
|
||||
if hasattr(complaint, "_prefetched_objects_cache"):
|
||||
complaint._prefetched_objects_cache.pop("involved_departments", None)
|
||||
|
||||
user = request.user
|
||||
if not user.is_px_admin():
|
||||
if user.is_hospital_admin() and complaint.hospital != user.hospital:
|
||||
@ -747,9 +769,12 @@ def complaint_detail(request, pk):
|
||||
"can_manage_actions": (
|
||||
user.is_px_admin()
|
||||
or (user.is_hospital_admin() and user.hospital == complaint.hospital)
|
||||
or (user.is_px_management() and user.hospital == complaint.hospital)
|
||||
or (user.is_px_employee() and user.hospital == complaint.hospital)
|
||||
or (complaint.assigned_to == user)
|
||||
or (
|
||||
not complaint.activated_at
|
||||
and (user.is_px_management() or user.is_px_employee())
|
||||
and user.hospital == complaint.hospital
|
||||
)
|
||||
),
|
||||
"can_admin": user.is_px_admin() or (user.is_hospital_admin() and user.hospital == complaint.hospital),
|
||||
"is_active_status": complaint.is_active_status,
|
||||
@ -1195,6 +1220,35 @@ def complaint_send_to(request, pk):
|
||||
now = timezone.now()
|
||||
|
||||
if complaint.department_id == department.pk:
|
||||
# Create/update CID for the primary department too so it appears
|
||||
# in the Involved Departments list and the department-detail queries.
|
||||
involved_dept, created = ComplaintInvolvedDepartment.objects.get_or_create(
|
||||
complaint=complaint,
|
||||
department=department,
|
||||
defaults={
|
||||
"role": "primary",
|
||||
"is_primary": True,
|
||||
"added_by": user,
|
||||
"forwarded_at": now,
|
||||
"sent": True,
|
||||
"sent_at": now,
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
involved_dept.forwarded_at = now
|
||||
involved_dept.sent = True
|
||||
involved_dept.sent_at = now
|
||||
involved_dept.routing_status = "sent"
|
||||
involved_dept.rejected_at = None
|
||||
involved_dept.rejected_by_staff = None
|
||||
involved_dept.rejection_reason = ""
|
||||
involved_dept.suggested_department = None
|
||||
involved_dept.save(update_fields=[
|
||||
"forwarded_at", "sent", "sent_at",
|
||||
"routing_status", "rejected_at", "rejected_by_staff",
|
||||
"rejection_reason", "suggested_department",
|
||||
])
|
||||
|
||||
complaint.sent_to_department = True
|
||||
complaint.sent_to_department_at = now
|
||||
if not complaint.forwarded_to_dept_at:
|
||||
@ -1371,6 +1425,123 @@ def complaint_send_to(request, pk):
|
||||
}, status=500)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["POST"])
|
||||
def complaint_send_dept_reminder(request, pk):
|
||||
"""Send a manual reminder to a department that hasn't responded yet.
|
||||
|
||||
Visible in Quick Actions after the complaint has been sent to a department.
|
||||
The user picks which department from a modal; this endpoint sends an email +
|
||||
SMS to that department's champion/manager and logs a ComplaintUpdate.
|
||||
"""
|
||||
from .models import ComplaintInvolvedDepartment, ComplaintUpdate
|
||||
from apps.notifications.services import NotificationService, get_email_header_html
|
||||
from apps.organizations.department_contacts import get_champion_and_manager
|
||||
from apps.core.utils import build_public_track_url
|
||||
|
||||
complaint = get_object_or_404(Complaint, pk=pk)
|
||||
user = request.user
|
||||
|
||||
if not can_manage_complaint(user, complaint):
|
||||
messages.error(request, _("You don't have permission to send reminders."))
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
inv_dept_id = request.POST.get("involved_department_id", "").strip()
|
||||
note = request.POST.get("note", "").strip()
|
||||
|
||||
if not inv_dept_id:
|
||||
messages.error(request, _("Please select a department."))
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
involved_dept = get_object_or_404(
|
||||
ComplaintInvolvedDepartment,
|
||||
pk=inv_dept_id,
|
||||
complaint=complaint,
|
||||
sent=True,
|
||||
response_submitted=False,
|
||||
)
|
||||
department = involved_dept.department
|
||||
|
||||
targets = get_champion_and_manager(department)
|
||||
if not targets:
|
||||
messages.error(
|
||||
request,
|
||||
_(f"Cannot send reminder to {department.get_localized_name()}. This department has no champion or manager assigned."),
|
||||
)
|
||||
return redirect("complaints:complaint_detail", pk=pk)
|
||||
|
||||
track_url = build_public_track_url("complaint", complaint.reference_number)
|
||||
notified = []
|
||||
sent_any = False
|
||||
|
||||
for target in targets:
|
||||
email = target.get("email") or ""
|
||||
phone = target.get("phone") or ""
|
||||
label = target.get("label") or ""
|
||||
display_name = target.get("display_name") or label
|
||||
|
||||
if email:
|
||||
try:
|
||||
NotificationService.send_email(
|
||||
email=email,
|
||||
subject=f"Reminder: Complaint #{complaint.reference_number} - Response Required",
|
||||
message=(
|
||||
f"This is a reminder that complaint #{complaint.reference_number} "
|
||||
f"is awaiting your department's response.\n\n"
|
||||
f"Track: {track_url}"
|
||||
),
|
||||
html_message=f"""
|
||||
<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
|
||||
@require_http_methods(["POST"])
|
||||
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"),
|
||||
# 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-dept-reminder/", ui_views.complaint_send_dept_reminder, name="complaint_send_dept_reminder"),
|
||||
# Collect Feedback (champion/manager compose questions for staff)
|
||||
path("<uuid:pk>/collect-feedback/", ui_views.collect_feedback_start, name="collect_feedback_start"),
|
||||
# Involved Staff Management
|
||||
|
||||
@ -3665,6 +3665,10 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
)
|
||||
investigation.final_reply = explanation_text
|
||||
investigation.status = InvestigationStatus.DIRECT_REPLY_IN_PROGRESS
|
||||
investigation.negligence_finding = negligence_finding
|
||||
investigation.policy_issue_finding = policy_issue_finding
|
||||
investigation.requires_improvement_project = requires_improvement_project
|
||||
investigation.improvement_project_note = improvement_project_note
|
||||
|
||||
import random
|
||||
|
||||
@ -3673,6 +3677,8 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
investigation.otp_sent_at = timezone.now()
|
||||
investigation.save(update_fields=[
|
||||
"final_reply", "status", "otp_code", "otp_sent_at",
|
||||
"negligence_finding", "policy_issue_finding",
|
||||
"requires_improvement_project", "improvement_project_note",
|
||||
])
|
||||
|
||||
sent_channels = []
|
||||
@ -3977,6 +3983,7 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
|
||||
# Auto-show OTP entry if a direct-reply OTP is still valid (e.g., user reloaded page)
|
||||
otp_sent_on_get = False
|
||||
draft_ctx = {}
|
||||
if not explanation.is_used:
|
||||
from apps.complaints.models import ChampionInvestigation, InvestigationStatus
|
||||
from datetime import timedelta
|
||||
@ -3987,6 +3994,14 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
).exclude(otp_code="").first()
|
||||
if existing and existing.otp_sent_at and timezone.now() <= existing.otp_sent_at + timedelta(minutes=10):
|
||||
otp_sent_on_get = True
|
||||
draft_ctx = {
|
||||
"final_reply": existing.final_reply or "",
|
||||
"negligence_finding": existing.negligence_finding or "",
|
||||
"policy_issue_finding": existing.policy_issue_finding or "",
|
||||
"requires_improvement_project": existing.requires_improvement_project or "",
|
||||
"improvement_project_note": existing.improvement_project_note or "",
|
||||
"consent_checked": True,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
@ -4005,6 +4020,7 @@ def complaint_explanation_form(request, complaint_id, token):
|
||||
"token": explanation.token,
|
||||
})
|
||||
),
|
||||
**draft_ctx,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -212,9 +212,10 @@ def user_create(request):
|
||||
form = UserCreateForm(request.POST, request=request)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
generated_password = getattr(form, '_generated_password', None)
|
||||
|
||||
staff_id = request.POST.get("staff_id")
|
||||
create_staff = request.POST.get("create_staff") == "on"
|
||||
|
||||
if staff_id:
|
||||
from apps.organizations.models import Staff
|
||||
try:
|
||||
@ -226,29 +227,46 @@ def user_create(request):
|
||||
))
|
||||
except Staff.DoesNotExist:
|
||||
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
|
||||
elif create_staff:
|
||||
from apps.organizations.services import StaffService
|
||||
staff_type = request.POST.get("staff_type", "other")
|
||||
job_title = request.POST.get("staff_job_title", "")
|
||||
try:
|
||||
staff = StaffService.create_staff_for_user(
|
||||
user, staff_type=staff_type, job_title=job_title, request=request
|
||||
)[0]
|
||||
messages.success(request, _("User '{}' created with staff record '{}'.").format(
|
||||
user.get_full_name(), staff.get_full_name()
|
||||
))
|
||||
except ValueError as e:
|
||||
messages.warning(request, _("User '{}' created but staff record creation failed: {}").format(
|
||||
user.get_full_name(), str(e)
|
||||
))
|
||||
else:
|
||||
messages.success(request, _("User '{}' created successfully.").format(user.get_full_name()))
|
||||
|
||||
password_was_set = bool(form.cleaned_data.get("password"))
|
||||
|
||||
try:
|
||||
base_url = f"{request.scheme}://{request.get_host()}"
|
||||
reset_token = PasswordResetTokenService.create_reset_token(user)
|
||||
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
|
||||
subject = _("Your PX360 Account Has Been Created")
|
||||
|
||||
if generated_password:
|
||||
if password_was_set:
|
||||
login_url = f"{base_url}/accounts/login/"
|
||||
html_message = render_to_string(
|
||||
"config/emails/user_created_email.html",
|
||||
{"user": user, "reset_url": reset_url, "temp_password": generated_password},
|
||||
{"user": user, "login_url": login_url},
|
||||
request=request,
|
||||
)
|
||||
plain_message = (
|
||||
f"Dear {user.get_full_name()},\n\n"
|
||||
f"An account has been created for you on PX360.\n\n"
|
||||
f"Your temporary password: {generated_password}\n\n"
|
||||
f"For security, please set your own password using the link below:\n{reset_url}\n\n"
|
||||
f"This link expires in 24 hours."
|
||||
f"You can log in at: {login_url}\n\n"
|
||||
f"Please contact your administrator for your login credentials."
|
||||
)
|
||||
subject = _("Your PX360 Account Has Been Created")
|
||||
else:
|
||||
reset_token = PasswordResetTokenService.create_reset_token(user)
|
||||
reset_url = PasswordResetTokenService.build_reset_url(base_url, reset_token)
|
||||
html_message = render_to_string(
|
||||
"config/emails/reset_password_email.html",
|
||||
{"user": user, "reset_url": reset_url},
|
||||
@ -260,7 +278,6 @@ def user_create(request):
|
||||
f"Use the link below to set your password:\n{reset_url}\n\n"
|
||||
f"This link expires in 24 hours."
|
||||
)
|
||||
subject = _("Your PX360 Account Has Been Created")
|
||||
|
||||
NotificationService.send_email(
|
||||
email=user.email,
|
||||
@ -270,8 +287,8 @@ def user_create(request):
|
||||
user=user,
|
||||
notification_type="system",
|
||||
)
|
||||
if generated_password:
|
||||
messages.info(request, _("Credentials sent to {}.").format(user.email))
|
||||
if password_was_set:
|
||||
messages.info(request, _("Welcome email sent to {}.").format(user.email))
|
||||
else:
|
||||
messages.info(request, _("Password setup link sent to {}.").format(user.email))
|
||||
except Exception as e:
|
||||
|
||||
@ -363,7 +363,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
|
||||
"""Read-only serializer for retrieving an appreciation."""
|
||||
|
||||
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||
reference_number = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Appreciation
|
||||
@ -379,11 +378,6 @@ class ExternalAppreciationRetrieveSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_reference_number(self, obj):
|
||||
if obj.metadata and "reference_number" in obj.metadata:
|
||||
return obj.metadata["reference_number"]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Suggestion (Feedback)
|
||||
@ -433,7 +427,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
|
||||
"""Read-only serializer for retrieving a suggestion."""
|
||||
|
||||
hospital_name = serializers.CharField(source="hospital.name", read_only=True)
|
||||
reference_number = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Feedback
|
||||
@ -455,11 +448,6 @@ class ExternalSuggestionRetrieveSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_reference_number(self, obj):
|
||||
if obj.metadata and "reference_number" in obj.metadata:
|
||||
return obj.metadata["reference_number"]
|
||||
return None
|
||||
|
||||
|
||||
class ExternalComplaintSatisfactionSerializer(serializers.Serializer):
|
||||
satisfaction = serializers.ChoiceField(
|
||||
|
||||
@ -155,7 +155,7 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-HOSP-NNNN)
|
||||
# Reference number generated by Complaint.save() (unified CMP-YYYYMM-NNNN)
|
||||
|
||||
complaint = Complaint.objects.create(
|
||||
patient=None,
|
||||
@ -187,6 +187,8 @@ class ExternalComplaintCreateView(ExternalAPIBase, APIView):
|
||||
message="Complaint submitted via external API.",
|
||||
)
|
||||
|
||||
reference_number = complaint.reference_number
|
||||
|
||||
# Trigger background tasks
|
||||
try:
|
||||
from apps.complaints.tasks import analyze_complaint_with_ai, notify_staff_new_item
|
||||
@ -596,13 +598,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
random_suffix = str(uuid.uuid4().int)[:6]
|
||||
reference_number = f"APR-{today}-{random_suffix}"
|
||||
|
||||
appreciation = Appreciation.objects.create(
|
||||
hospital=data["hospital"],
|
||||
message_en=data["message"],
|
||||
@ -613,7 +608,6 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
|
||||
visibility=AppreciationVisibility.PUBLIC,
|
||||
metadata={
|
||||
"source": "external_api",
|
||||
"reference_number": reference_number,
|
||||
"submitted_by_name": data["contact_name"],
|
||||
"submitted_by_phone": data["contact_phone"],
|
||||
},
|
||||
@ -636,7 +630,7 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"reference_number": reference_number,
|
||||
"reference_number": appreciation.reference_number,
|
||||
"status": appreciation.status,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
@ -646,22 +640,22 @@ class ExternalAppreciationCreateView(ExternalAPIBase, APIView):
|
||||
class ExternalAppreciationRetrieveView(ExternalAPIBase, APIView):
|
||||
"""
|
||||
GET /api/v1/external/appreciations/
|
||||
GET /api/v1/external/appreciations/<id>/
|
||||
GET /api/v1/external/appreciations/<reference_number>/
|
||||
"""
|
||||
|
||||
entity_name = "appreciations"
|
||||
|
||||
def get(self, request, pk=None):
|
||||
def get(self, request, reference_number=None):
|
||||
access_err = self.check_entity_access()
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
if pk:
|
||||
return self._retrieve(request, pk)
|
||||
if reference_number:
|
||||
return self._retrieve(request, reference_number)
|
||||
return self._list(request)
|
||||
|
||||
def _retrieve(self, request, pk):
|
||||
qs = Appreciation.all_objects.filter(pk=pk)
|
||||
def _retrieve(self, request, reference_number):
|
||||
qs = Appreciation.all_objects.filter(reference_number=reference_number)
|
||||
qs = self.filter_queryset_by_hospital(qs)
|
||||
appreciation = qs.first()
|
||||
if not appreciation:
|
||||
@ -734,13 +728,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
|
||||
"other": FeedbackCategory.OTHER,
|
||||
}
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
random_suffix = str(_uuid.uuid4().int)[:6]
|
||||
reference_number = f"SG-{today}-{random_suffix}"
|
||||
|
||||
title = data.get("title") or data["message"][:100]
|
||||
|
||||
feedback = Feedback.objects.create(
|
||||
@ -756,7 +743,6 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
|
||||
status=FeedbackStatus.SUBMITTED,
|
||||
metadata={
|
||||
"source": "external_api",
|
||||
"reference_number": reference_number,
|
||||
"suggestion_area": data.get("category", "general"),
|
||||
},
|
||||
)
|
||||
@ -780,7 +766,7 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
|
||||
return Response(
|
||||
{
|
||||
"success": True,
|
||||
"reference_number": reference_number,
|
||||
"reference_number": feedback.reference_number,
|
||||
"status": feedback.status,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
@ -790,22 +776,22 @@ class ExternalSuggestionCreateView(ExternalAPIBase, APIView):
|
||||
class ExternalSuggestionRetrieveView(ExternalAPIBase, APIView):
|
||||
"""
|
||||
GET /api/v1/external/suggestions/
|
||||
GET /api/v1/external/suggestions/<id>/
|
||||
GET /api/v1/external/suggestions/<reference_number>/
|
||||
"""
|
||||
|
||||
entity_name = "suggestions"
|
||||
|
||||
def get(self, request, pk=None):
|
||||
def get(self, request, reference_number=None):
|
||||
access_err = self.check_entity_access()
|
||||
if access_err:
|
||||
return access_err
|
||||
|
||||
if pk:
|
||||
return self._retrieve(request, pk)
|
||||
if reference_number:
|
||||
return self._retrieve(request, reference_number)
|
||||
return self._list(request)
|
||||
|
||||
def _retrieve(self, request, pk):
|
||||
qs = Feedback.all_objects.filter(pk=pk)
|
||||
def _retrieve(self, request, reference_number):
|
||||
qs = Feedback.all_objects.filter(reference_number=reference_number)
|
||||
qs = self.filter_queryset_by_hospital(qs)
|
||||
feedback = qs.first()
|
||||
if not feedback:
|
||||
|
||||
@ -119,7 +119,7 @@ urlpatterns = [
|
||||
name="external-appreciation-list",
|
||||
),
|
||||
path(
|
||||
"appreciations/<uuid:pk>/",
|
||||
"appreciations/<str:reference_number>/",
|
||||
ExternalAppreciationRetrieveView.as_view(),
|
||||
name="external-appreciation-detail",
|
||||
),
|
||||
@ -135,7 +135,7 @@ urlpatterns = [
|
||||
name="external-suggestion-list",
|
||||
),
|
||||
path(
|
||||
"suggestions/<uuid:pk>/",
|
||||
"suggestions/<str:reference_number>/",
|
||||
ExternalSuggestionRetrieveView.as_view(),
|
||||
name="external-suggestion-detail",
|
||||
),
|
||||
|
||||
@ -312,6 +312,18 @@ class NotificationService:
|
||||
Returns:
|
||||
NotificationLog instance
|
||||
"""
|
||||
# Auto-wrap plain-text messages in the branded base email template so
|
||||
# every notification has the hospital logo, card, and footer. Callers
|
||||
# that already supply html_message are left untouched.
|
||||
if html_message is None and message:
|
||||
from django.template.loader import render_to_string as _render_to_string
|
||||
|
||||
content_html = "<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
|
||||
|
||||
# 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
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -554,6 +556,14 @@ def send_observation_dept_response_reminders():
|
||||
):
|
||||
for recipient in recipients:
|
||||
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(
|
||||
email=recipient.email,
|
||||
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"Please submit your response before the deadline."
|
||||
),
|
||||
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
|
||||
related_object=observation,
|
||||
)
|
||||
except Exception as e:
|
||||
@ -581,6 +592,14 @@ def send_observation_dept_response_reminders():
|
||||
):
|
||||
for recipient in recipients:
|
||||
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(
|
||||
email=recipient.email,
|
||||
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"Please submit your response immediately."
|
||||
),
|
||||
html_message=render_to_string("emails/observation_dept_response_reminder.html", ctx),
|
||||
related_object=observation,
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@ -161,6 +161,70 @@ class StaffService:
|
||||
|
||||
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
|
||||
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)
|
||||
else:
|
||||
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)
|
||||
elif user.is_basic_staff() and user.department:
|
||||
queryset = queryset.filter(id=user.department.id)
|
||||
@ -2055,12 +2055,14 @@ def department_detail(request, pk):
|
||||
).first()
|
||||
if not explanation and department.champion:
|
||||
import secrets as _secrets
|
||||
explanation = ComplaintExplanation.objects.create(
|
||||
explanation, _created = ComplaintExplanation.objects.get_or_create(
|
||||
complaint=pc.complaint,
|
||||
staff=department.champion,
|
||||
token=_secrets.token_urlsafe(32),
|
||||
explanation="",
|
||||
is_used=False,
|
||||
defaults={
|
||||
"token": _secrets.token_urlsafe(32),
|
||||
"explanation": "",
|
||||
"is_used": False,
|
||||
},
|
||||
)
|
||||
if explanation:
|
||||
if explanation.is_used and needs_resubmit:
|
||||
@ -2117,11 +2119,17 @@ def department_detail(request, pk):
|
||||
"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(
|
||||
complaint__department=department,
|
||||
staff__department=department,
|
||||
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:
|
||||
pending_actions.append({
|
||||
"type": "complaint_explanation",
|
||||
|
||||
@ -581,7 +581,7 @@
|
||||
"name": "Created",
|
||||
"status": "Created",
|
||||
"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": {
|
||||
"raw": "{{base_url}}/api/v1/external/appreciations/<uuid>/",
|
||||
"raw": "{{base_url}}/api/v1/external/appreciations/<reference_number>/",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "external", "appreciations", "<uuid>", ""]
|
||||
"path": ["api", "v1", "external", "appreciations", "<reference_number>", ""]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
@ -669,7 +669,7 @@
|
||||
"name": "Created",
|
||||
"status": "Created",
|
||||
"code": 201,
|
||||
"body": "{\n \"success\": true,\n \"reference_number\": \"SG-20260609-316316\",\n \"status\": \"submitted\"\n}"
|
||||
"body": "{\n \"success\": true,\n \"reference_number\": \"SGT-202607-0001\",\n \"status\": \"submitted\"\n}"
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -714,9 +714,9 @@
|
||||
}
|
||||
],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/external/suggestions/<uuid>/",
|
||||
"raw": "{{base_url}}/api/v1/external/suggestions/<reference_number>/",
|
||||
"host": ["{{base_url}}"],
|
||||
"path": ["api", "v1", "external", "suggestions", "<uuid>", ""]
|
||||
"path": ["api", "v1", "external", "suggestions", "<reference_number>", ""]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
|
||||
@ -452,7 +452,7 @@ POST /api/v1/external/appreciations/
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reference_number": "APR-20260609-239270",
|
||||
"reference_number": "APR-202607-0001",
|
||||
"status": "draft"
|
||||
}
|
||||
```
|
||||
@ -461,7 +461,7 @@ POST /api/v1/external/appreciations/
|
||||
|
||||
```
|
||||
GET /api/v1/external/appreciations/list/
|
||||
GET /api/v1/external/appreciations/<uuid>/
|
||||
GET /api/v1/external/appreciations/<reference_number>/
|
||||
```
|
||||
|
||||
**Retrieve Response:**
|
||||
@ -469,7 +469,7 @@ GET /api/v1/external/appreciations/<uuid>/
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"reference_number": "APR-20260609-239270",
|
||||
"reference_number": "APR-202607-0001",
|
||||
"message_en": "Dr. Ahmed was incredibly kind and thorough.",
|
||||
"status": "draft",
|
||||
"hospital_name": "Al Nuzha",
|
||||
@ -506,7 +506,7 @@ POST /api/v1/external/suggestions/
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reference_number": "SG-20260609-316316",
|
||||
"reference_number": "SGT-202607-0001",
|
||||
"status": "submitted"
|
||||
}
|
||||
```
|
||||
@ -515,7 +515,7 @@ POST /api/v1/external/suggestions/
|
||||
|
||||
```
|
||||
GET /api/v1/external/suggestions/list/
|
||||
GET /api/v1/external/suggestions/<uuid>/
|
||||
GET /api/v1/external/suggestions/<reference_number>/
|
||||
```
|
||||
|
||||
**Retrieve Response:**
|
||||
@ -523,7 +523,7 @@ GET /api/v1/external/suggestions/<uuid>/
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"reference_number": "SG-20260609-316316",
|
||||
"reference_number": "SGT-202607-0001",
|
||||
"title": "Digital queue system",
|
||||
"message": "Please implement a digital queue management system.",
|
||||
"category": "technology",
|
||||
@ -669,5 +669,5 @@ All list endpoints return paginated results:
|
||||
| Complaint | `CMP-` | `CMP-YYYYMMDD-XXXXXX` |
|
||||
| Inquiry | `INQ-` | `INQ-YYYYMMDD-XXXXXX` |
|
||||
| Observation | `OBS-` | `OBS-XXXXXX` (auto-generated by model) |
|
||||
| Appreciation | `APR-` | `APR-YYYYMMDD-XXXXXX` |
|
||||
| Suggestion | `SG-` | `SG-YYYYMMDD-XXXXXX` |
|
||||
| Appreciation | `APR-` | `APR-YYYYMM-NNNN` |
|
||||
| Suggestion | `SGT-` | `SGT-YYYYMM-NNNN` |
|
||||
|
||||
@ -39,7 +39,7 @@ So: **a recognition engine whose originating actors can be (a) PX/hospital staff
|
||||
|
||||
**(b) Public portal** (`ui_views.py:1196`): Inputs `contact_name`, `contact_phone`, `message`, `hospital`, optional `staff_name`/`department`/`section`. Required: name, phone, message, hospital (`ui_views.py:1224`). Creates with `status=DRAFT`, `visibility=PUBLIC`, `is_anonymous=False`, `category=None`, metadata `{source:"public_form", ...}` (`ui_views.py:1239`). Fires `notify_staff_new_item.delay("appreciation", ...)` (`ui_views.py:1258`). Returns `reference_number`.
|
||||
|
||||
**(c) External API** (`integrations/api_views.py:606`): Creates DRAFT, visibility PUBLIC, metadata `{source:"external_api", ...}`. Builds its own `APR-YYYYMMDD-<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`):
|
||||
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)
|
||||
- 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
|
||||
- View: `source_user_create_suggestion` (`apps/px_sources/ui_views.py:1387`); `@login_required`; guarded by `source_user.can_create_suggestions`.
|
||||
@ -302,7 +302,7 @@ Raw-unit model of the IT-export pipeline. Imported in monthly batches via `Comme
|
||||
## Appendix — Flagged gaps / ambiguities
|
||||
|
||||
1. **Reopen unreachable via UI** after closure (Quick Actions hidden when `closed`, `feedback_detail.html:299`). CLOSED→REOPENED allowed by map but no staff UI button.
|
||||
2. **`reference_number` divergence** — `save()` generates canonical `SGT-YYYYMM-NNNN`; public endpoint returns different `SG-{uuid[:8]}` (`views.py:1026`); external API generates `SG-{YYYYMMDD}-{random6}` in metadata only. Public cannot track by real reference.
|
||||
2. **`reference_number` divergence (external API resolved)** — `save()` generates canonical `SGT-YYYYMM-NNNN`; the external API now returns and retrieves by this canonical reference. **Public portal still diverges**: the public submit endpoint returns a different `SG-{uuid[:8]}` (`views.py:1026`), so the public cannot track by the real reference via the web portal.
|
||||
3. **`FeedbackForm` unused for creation** — `feedback_create` builds manually, bypassing form `clean()`.
|
||||
4. **Transitions unenforced at DB/model/admin** — only `feedback_change_status` enforces.
|
||||
5. **No outbound communication to suggester** by design.
|
||||
|
||||
@ -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">
|
||||
<i data-lucide="printer" class="w-4 h-4"></i> {% trans "PDF View" %}
|
||||
</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">
|
||||
{% trans "Resolve Case" %}
|
||||
</button>
|
||||
@ -390,25 +390,25 @@
|
||||
<button class="py-4 text-sm tab-active" onclick="switchTab('details')" id="tab-details">
|
||||
{% trans "Details" %}
|
||||
</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 %}"
|
||||
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments">
|
||||
<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 can_admin or not complaint.is_active_status %}onclick="switchTab('departments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-departments">
|
||||
{% 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 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 %}"
|
||||
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
|
||||
<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 can_admin or not complaint.is_active_status %}onclick="switchTab('timeline')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-timeline">
|
||||
{% 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>
|
||||
{% 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 %}"
|
||||
{% if complaint.assigned_to == current_user or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments">
|
||||
{% 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 can_admin or not complaint.is_active_status %}onclick="switchTab('attachments')"{% else %}onclick="showActivationRequired()"{% endif %} id="tab-attachments">
|
||||
{% 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 %}
|
||||
{% 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"
|
||||
{% if complaint.assigned_to == current_user 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>
|
||||
<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 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 not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
|
||||
{% trans "PX Actions" %}
|
||||
{% 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>
|
||||
@ -416,36 +416,36 @@
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% 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"
|
||||
{% if complaint.assigned_to == current_user 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" %}
|
||||
{% if not complaint.assigned_to == current_user and complaint.is_active_status %}<i data-lucide="lock" class="w-3 h-3"></i>{% 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 %} flex items-center gap-2"
|
||||
{% 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 not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "AI Analysis" %}
|
||||
{% 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 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"
|
||||
{% if complaint.assigned_to == current_user 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>
|
||||
<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 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 not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
|
||||
{% 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>
|
||||
{% 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"
|
||||
{% if complaint.assigned_to == current_user 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>
|
||||
{% 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 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 not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i>
|
||||
{% trans "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>
|
||||
{% 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 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"
|
||||
{% if complaint.assigned_to == current_user 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" %}
|
||||
<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 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 not can_admin and complaint.is_active_status %}text-slate-300{% endif %}"></i> {% trans "Notes" %}
|
||||
{% 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>
|
||||
{% 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>
|
||||
</nav>
|
||||
|
||||
@ -598,7 +598,7 @@
|
||||
<span class="text-sm font-bold text-yellow-700">{% trans "Pending Approval" %}</span>
|
||||
</div>
|
||||
<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">
|
||||
<form method="post" action="{% url 'complaints:approve_ovr_escalation' pk=complaint.pk %}">
|
||||
{% csrf_token %}
|
||||
@ -834,10 +834,10 @@
|
||||
{% csrf_token %}
|
||||
<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"
|
||||
{% if complaint.assigned_to == current_user %}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>
|
||||
{% if complaint.activated_at %}disabled style="opacity: 0.5; cursor: not-allowed;"{% endif %}>
|
||||
<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">
|
||||
{% if complaint.assigned_to == current_user %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %}
|
||||
{% if complaint.activated_at %}{% trans "Activated" %}{% else %}{% trans "Activate" %}{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
@ -876,6 +876,12 @@
|
||||
</span>
|
||||
</button>
|
||||
</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 %}
|
||||
{% else %}
|
||||
<!-- Not yet activated: only Activate / Assign / Close are available -->
|
||||
@ -1251,6 +1257,43 @@
|
||||
</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 -->
|
||||
<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">
|
||||
@ -1371,7 +1414,7 @@ function closeModal(modalId) {
|
||||
// Close modal when clicking outside the dialog
|
||||
window.onclick = function(event) {
|
||||
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) {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
@ -1544,6 +1587,11 @@ function saveTaxonomy() {
|
||||
}
|
||||
|
||||
// ─── Send to Department modal (department + staff selects) ───
|
||||
function showReminderModal() {
|
||||
var m = document.getElementById('reminderModal');
|
||||
if (m) m.style.display = 'flex';
|
||||
}
|
||||
|
||||
function openSendToDeptModal() {
|
||||
var m = document.getElementById('sendToDeptModal');
|
||||
if (!m) return;
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% 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 %}
|
||||
<p style="margin: 0 0 15px;">
|
||||
@ -10,20 +10,23 @@
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<td style="padding: 20px;">
|
||||
<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>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{% if reset_url %}
|
||||
<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>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
||||
@ -36,6 +39,22 @@
|
||||
</td>
|
||||
</tr>
|
||||
</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;">
|
||||
{% trans "If you did not expect this account creation, please contact your system administrator immediately." %}
|
||||
|
||||
@ -180,9 +180,41 @@
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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>
|
||||
@ -358,7 +390,7 @@
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
@ -419,7 +451,7 @@
|
||||
</li>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
</section>
|
||||
@ -461,35 +493,63 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// ===== Department loading =====
|
||||
function loadDepartments(hospitalId, selectedDept) {
|
||||
if (!departmentSelect) return;
|
||||
var ts = departmentSelect.tomselect;
|
||||
var placeholderText = '{% trans "Select Department" %}';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const results = data.results || data;
|
||||
departmentSelect.innerHTML = '<option value="">{% trans "Select Department" %}</option>';
|
||||
|
||||
if (ts) {
|
||||
ts.clear();
|
||||
ts.clearOptions();
|
||||
ts.addOption({value: '', text: placeholderText});
|
||||
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) {
|
||||
departmentSelect.value = selectedDept;
|
||||
} else if ('{{ form.department.value }}') {
|
||||
departmentSelect.value = '{{ form.department.value }}';
|
||||
var selectVal = selectedDept || ('{{ form.department.value }}' || '');
|
||||
if (selectVal) {
|
||||
if (ts) { ts.setValue(selectVal, true); }
|
||||
else { departmentSelect.value = selectVal; }
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading departments:', error);
|
||||
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 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) {
|
||||
let debounceTimer;
|
||||
|
||||
@ -603,6 +695,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
staffSearchInputWrap.classList.add('hidden');
|
||||
selectedStaffChip.classList.remove('hidden');
|
||||
|
||||
disableCreateStaff();
|
||||
|
||||
// Auto-fill fields
|
||||
const fields = {
|
||||
'id_first_name': data.first_name,
|
||||
@ -636,8 +730,34 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
staffSearchInputWrap.classList.remove('hidden');
|
||||
staffSearchInput.value = '';
|
||||
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') {
|
||||
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>
|
||||
|
||||
<!-- 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 %}
|
||||
<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 %}">
|
||||
@ -324,7 +324,7 @@
|
||||
<!-- Departments -->
|
||||
{% if not user.is_basic_staff and not user.is_department_manager and not user.is_director %}
|
||||
{% 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 %}"
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user