HH/apps/appreciation/ui_views.py
ismail 615b8467ef fix(review): observation dept_manager button gate + appreciation status guard
Two issues from the whole-branch review:

1. (Important) Observation detail's can_respond_to_department context var
   did not include is_department_manager, so even though Task 2 let dept
   managers pass the view's permission check, the 'Submit Response'
   button stayed hidden from them. Now the context var matches the view
   predicate. Regression test added.

2. (Minor) appreciation_list had no guard against now-invalid status
   query params (?status=acknowledged from an old bookmark would show
   an unexplained empty list). Unknown values are now silently reset
   to 'all statuses'.
2026-07-20 22:38:12 +03:00

1623 lines
61 KiB
Python

"""
Appreciation UI views - Server-rendered templates for appreciation management
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.paginator import Paginator
from django.db.models import Q, Count
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from apps.accounts.models import User
from apps.accounts.services import StaffActivityService
from apps.core.services import AuditService
from apps.organizations.models import Department, Hospital, Staff
from .models import (
Appreciation,
AppreciationBadge,
AppreciationCategory,
AppreciationStatus,
AppreciationVisibility,
AppreciationStats,
UserBadge,
)
# ============================================================================
# APPRECIATION LIST & DETAIL VIEWS
# ============================================================================
@login_required
def appreciation_list(request):
queryset = Appreciation.objects.select_related(
"hospital", "department", "category"
).order_by("-created_at")
user = request.user
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_px_management() or user.is_px_employee():
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_department_manager() and user.department:
queryset = queryset.filter(department=user.department)
elif user.is_champion() and user.department:
queryset = queryset.filter(department=user.department)
elif user.is_source_user():
queryset = queryset.filter(created_by=user)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
status_filter_val = request.GET.get("status", "")
# Guard: silently reset unknown/legacy status values (e.g. a bookmarked
# ?status=acknowledged from before the 3-state collapse) to "all" so the
# user doesn't see an unexplained empty list.
if status_filter_val and status_filter_val not in dict(AppreciationStatus.choices):
status_filter_val = ""
if status_filter_val:
queryset = queryset.filter(status=status_filter_val)
search_query = request.GET.get("search", "").strip()
if search_query:
queryset = queryset.filter(
Q(message_en__icontains=search_query)
| Q(message_ar__icontains=search_query)
| Q(metadata__submitted_by_name__icontains=search_query)
)
base_qs = Appreciation.objects.all()
if user.is_px_admin():
if selected_hospital:
base_qs = base_qs.filter(hospital=selected_hospital)
elif user.is_hospital_admin() and user.hospital:
base_qs = base_qs.filter(hospital=user.hospital)
elif user.is_px_management() or user.is_px_employee():
if user.hospital:
base_qs = base_qs.filter(hospital=user.hospital)
elif user.is_department_manager() and user.department:
base_qs = base_qs.filter(department=user.department)
elif user.is_champion() and user.department:
base_qs = base_qs.filter(department=user.department)
elif user.is_source_user():
base_qs = base_qs.filter(created_by=user)
elif user.hospital:
base_qs = base_qs.filter(hospital=user.hospital)
else:
base_qs = base_qs.none()
stats = {
"total": base_qs.count(),
"draft": base_qs.filter(status=AppreciationStatus.DRAFT).count(),
"activated": base_qs.filter(
status=AppreciationStatus.ACTIVATED
).count(),
"sent": base_qs.filter(status=AppreciationStatus.SENT).count(),
}
paginator = Paginator(queryset, 25)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
context = {
"page_obj": page_obj,
"appreciations": page_obj.object_list,
"stats": stats,
"status_filter": status_filter_val,
"search_query": search_query,
}
return render(request, "appreciation/appreciation_list.html", context)
@login_required
def appreciation_detail(request, pk):
appreciation = get_object_or_404(
Appreciation.objects.select_related("hospital", "department", "category"),
pk=pk,
)
user = request.user
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("You don't have permission to view this appreciation."))
return redirect("appreciation:appreciation_list")
metadata = appreciation.metadata or {}
staff_queryset = Staff.objects.filter(status="active").select_related("department").order_by("first_name")
if user.hospital and not user.is_px_admin():
staff_queryset = staff_queryset.filter(hospital=user.hospital)
departments = Department.objects.filter(status="active").order_by("name")
if user.hospital and not user.is_px_admin():
departments = departments.filter(hospital=user.hospital)
categories = AppreciationCategory.objects.filter(is_active=True).order_by("order", "name_en")
# Sendable departments for the "Send to Department" modal
from apps.organizations.department_contacts import has_contact_target
hospital_departments = []
if appreciation.hospital:
hospital_departments = [
d for d in Department.objects.filter(
hospital=appreciation.hospital, status="active"
).select_related("champion", "manager", "manager__staff_profile").order_by("name")
if has_contact_target(d)
]
from django.contrib.contenttypes.models import ContentType
appreciation_ct = ContentType.objects.get_for_model(appreciation)
generic_notes = appreciation.notes.select_related("created_by").all()
has_recipient = appreciation.recipient is not None
is_activated = appreciation.status == AppreciationStatus.ACTIVATED
context = {
"appreciation": appreciation,
"metadata": metadata,
"staff_list": staff_queryset,
"departments": departments,
"categories": categories,
"hospital_departments": hospital_departments,
"can_activate": appreciation.status == AppreciationStatus.DRAFT,
"can_send": is_activated and appreciation.department_id is not None,
"has_recipient": has_recipient,
"is_recipient": False,
"content_type_id": appreciation_ct.pk,
"object_id": appreciation.pk,
"notes": generic_notes,
"notes_count": generic_notes.count(),
}
_apr_steps = [
{"label": _("Activate"), "icon": "zap", "done": appreciation.activated_at is not None},
{"label": _("Select Staff"), "icon": "user-check", "done": has_recipient or appreciation.sent_at is not None},
{"label": _("Send"), "icon": "send", "done": appreciation.sent_at is not None},
]
_apr_next = next((i for i, s in enumerate(_apr_steps) if not s["done"]), len(_apr_steps))
if _apr_next < len(_apr_steps):
_apr_steps[_apr_next]["current"] = True
context["workflow_steps"] = _apr_steps
return render(request, "appreciation/appreciation_detail.html", context)
@login_required
def appreciation_create(request):
"""Internal form for creating a new appreciation (department-targeted)."""
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
messages.error(request, _("You don't have permission to create appreciations."))
return redirect("appreciation:appreciation_list")
from .forms import AppreciationForm
hospital = getattr(request, "tenant_hospital", None) or user.hospital
if request.method == "POST":
form = AppreciationForm(request.POST, hospital=hospital)
if form.is_valid():
appreciation = form.save(commit=False)
appreciation.sender = user
appreciation.hospital = hospital
appreciation.status = AppreciationStatus.DRAFT
appreciation.save()
form.save_m2m()
staff_id = request.POST.get("staff_id")
if staff_id:
try:
from django.contrib.contenttypes.models import ContentType
staff_ct = ContentType.objects.get_for_model(Staff)
staff = Staff.objects.get(pk=staff_id, status="active")
appreciation.recipient_content_type = staff_ct
appreciation.recipient_object_id = staff.id
appreciation.save(update_fields=["recipient_content_type", "recipient_object_id"])
except Staff.DoesNotExist:
pass
messages.success(request, _("Appreciation created successfully."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
else:
messages.error(request, _("Please correct the errors below."))
else:
form = AppreciationForm(hospital=hospital)
context = {
"form": form,
"hospital": hospital,
}
return render(request, "appreciation/appreciation_create.html", context)
@login_required
@require_http_methods(["POST"])
def appreciation_activate(request, pk):
appreciation = get_object_or_404(Appreciation, pk=pk)
if appreciation.status != AppreciationStatus.DRAFT:
messages.error(request, _("This appreciation has already been processed."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
user = request.user
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("Permission denied."))
return redirect("appreciation:appreciation_list")
appreciation.status = AppreciationStatus.ACTIVATED
appreciation.activated_at = timezone.now()
appreciation.activated_by = user
appreciation.save()
StaffActivityService.log_from_request(
request,
activity_type="create",
description=f"Activated appreciation {appreciation.pk}",
content_object=appreciation,
module="appreciation",
)
AuditService.log_event(
event_type="appreciation_activated",
description=f"Appreciation {appreciation.pk} activated by {user.get_full_name()}",
content_object=appreciation,
)
try:
from apps.core.ai_service import AIService
analysis_result = AIService.chat_completion(
prompt=f'Analyze this patient appreciation message and provide:\n'
f'1. A summary of what the patient appreciated (in English and Arabic)\n'
f'2. Key themes mentioned\n'
f'3. Suggested category if not already set\n'
f'4. Tone analysis\n\n'
f'Message: "{appreciation.message_en}"\n\n'
f'Respond in JSON format with keys:\n'
f'- summary_en: English summary\n'
f'- summary_ar: Arabic summary\n'
f'- themes: List of key themes\n'
f'- tone: "warm", "formal", or "casual"\n'
f'- suggested_response_en: Suggested response in English\n'
f'- suggested_response_ar: Suggested response in Arabic',
system_prompt="You are analyzing patient appreciation messages. Always respond with valid JSON.",
response_format="json_object",
)
import json
analysis_data = json.loads(analysis_result)
appreciation.mark_ai_analyzed(analysis_data)
except Exception as e:
import logging
logging.getLogger(__name__).error(f"AI analysis failed for appreciation {appreciation.pk}: {str(e)}")
messages.success(request, _("Appreciation activated successfully."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
@login_required
@require_http_methods(["POST"])
def appreciation_select_recipient(request, pk):
"""Select the department and optional staff recipient for an appreciation (without sending)."""
appreciation = get_object_or_404(Appreciation, pk=pk)
if appreciation.status != AppreciationStatus.ACTIVATED:
messages.error(request, _("Appreciation must be activated before selecting a recipient."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
user = request.user
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("Permission denied."))
return redirect("appreciation:appreciation_list")
department_id = request.POST.get("department_id")
staff_id = request.POST.get("staff_id")
if request.POST.get("clear_recipient"):
appreciation.department = None
appreciation.recipient_content_type = None
appreciation.recipient_object_id = None
appreciation.save(update_fields=["department", "recipient_content_type", "recipient_object_id"])
messages.info(request, _("Recipient cleared. Please select a new recipient."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
if not department_id:
messages.error(request, _("Please select a department."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
try:
department = Department.objects.get(id=department_id, status="active")
except Department.DoesNotExist:
messages.error(request, _("Invalid department selected."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
appreciation.department = department
if staff_id:
try:
from django.contrib.contenttypes.models import ContentType
staff_ct = ContentType.objects.get_for_model(Staff)
staff = Staff.objects.get(pk=staff_id, status="active")
appreciation.recipient_content_type = staff_ct
appreciation.recipient_object_id = staff.id
except Staff.DoesNotExist:
pass
else:
appreciation.recipient_content_type = None
appreciation.recipient_object_id = None
appreciation.save()
messages.success(request, _("Recipient selected: {}").format(department.name))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
@login_required
@require_http_methods(["POST"])
def appreciation_send(request, pk):
appreciation = get_object_or_404(Appreciation, pk=pk)
if appreciation.status != AppreciationStatus.ACTIVATED:
messages.error(request, _("This appreciation must be activated before sending."))
return redirect("appreciation:appreciation_detail", pk=pk)
user = request.user
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
if not (user.hospital and appreciation.hospital_id == user.hospital_id):
messages.error(request, _("Permission denied."))
return redirect("appreciation:appreciation_list")
send_to_manager = request.POST.get("send_to_manager") == "on"
send_to_department = request.POST.get("send_to_department") == "on"
custom_message = request.POST.get("custom_message", "").strip()
cc_emails = request.POST.get("cc_emails", "").strip()
appreciation.send_to_manager = send_to_manager
appreciation.send_to_department = send_to_department
appreciation.custom_message = custom_message
cc_list = [e.strip() for e in cc_emails.split(",") if e.strip()] if cc_emails else []
appreciation.cc_list = cc_list
appreciation.send()
StaffActivityService.log_from_request(
request,
activity_type="send",
description=f"Sent appreciation {appreciation.pk} to {appreciation.get_recipient_name()}",
content_object=appreciation,
module="appreciation",
)
AuditService.log_event(
event_type="appreciation_sent",
description=f"Appreciation {appreciation.pk} sent to {appreciation.get_recipient_name()}",
user=user,
content_object=appreciation,
metadata={
"send_to_manager": send_to_manager,
"send_to_department": send_to_department,
"cc_count": len(cc_list),
},
)
messages.success(request, _("Appreciation sent successfully."))
return redirect("appreciation:appreciation_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def appreciation_send_to(request, pk):
"""Unified AJAX endpoint to send an appreciation to a person or department.
Mirrors the inquiry/complaint send-to flow used by the shared modal
(components/send_to_modal.html). Requires the appreciation to be activated
first; advances it to SENT on success.
"""
appreciation = get_object_or_404(Appreciation, pk=pk)
user = request.user
# Permission
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_department_manager()
or user.is_px_management() or user.is_px_employee()
):
return JsonResponse(
{"success": False, "error": str(_("You don't have permission to send this appreciation."))},
status=403,
)
# Must be activated before it can be sent
if appreciation.status != AppreciationStatus.ACTIVATED:
return JsonResponse(
{"success": False, "error": str(_("Activate this appreciation before sending it to a department."))},
status=400,
)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
email_subject = request.POST.get("email_subject", "").strip()
email_body = request.POST.get("email_body", "").strip()
try:
from apps.notifications.services import NotificationService, get_email_header_html
if recipient_type == "person":
person_id = request.POST.get("person_id")
if not person_id:
return JsonResponse(
{"success": False, "error": str(_("Please select a person."))}, status=400
)
try:
person = User.objects.get(id=person_id)
except User.DoesNotExist:
return JsonResponse(
{"success": False, "error": str(_("User not found."))}, status=400
)
if person.email:
send_subject = email_subject or f"Appreciation Shared - {appreciation.reference_number}"
send_body = email_body or (
f"An appreciation ({appreciation.reference_number}) has been shared with you."
)
NotificationService.send_email(
email=person.email,
subject=send_subject,
message=send_body,
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;">Appreciation Shared With You</h2>
<p>An appreciation <strong>#{appreciation.reference_number}</strong> has been shared with you.</p>
<p><strong>Message:</strong> {appreciation.message_en or 'N/A'}</p>
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
<p><a href="https://{request.get_host()}/appreciation/detail/{appreciation.pk}/">View Appreciation</a></p>
</div>
</div>
""",
related_object=appreciation,
)
message = f"Appreciation sent to {person.get_full_name()}."
else: # department
department_id = request.POST.get("department_id")
if not department_id:
return JsonResponse(
{"success": False, "error": str(_("Please select a department."))}, status=400
)
try:
department = Department.objects.get(id=department_id, status="active")
except Department.DoesNotExist:
return JsonResponse(
{"success": False, "error": str(_("Department not found."))}, status=400
)
# Auto-target the department's champion and manager (no contact-person picker)
from apps.organizations.department_contacts import get_champion_and_manager
targets = get_champion_and_manager(department)
if not targets:
return JsonResponse(
{"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending."))},
status=400,
)
# Route to the chosen department
appreciation.department = department
appreciation.send_to_department = True
if note:
appreciation.custom_message = note
# Set the appreciated staff member as the recipient (if selected)
staff_id = request.POST.get("staff_id", "").strip()
if staff_id:
try:
appreciated_staff = Staff.objects.get(id=staff_id)
from django.contrib.contenttypes.models import ContentType as CT
staff_ct = CT.objects.get_for_model(Staff)
appreciation.recipient_content_type = staff_ct
appreciation.recipient_object_id = appreciated_staff.id
except Staff.DoesNotExist:
pass
# Advance to SENT (validates status is ACTIVATED)
appreciation.send()
# Send notifications (email + SMS to champion, manager, staff) in the background
from apps.appreciation.tasks import send_appreciation_notifications
send_appreciation_notifications.delay(
str(appreciation.pk),
staff_id=staff_id or None,
note=note,
email_subject=email_subject,
email_body=email_body,
host=request.get_host(),
)
message = f"Appreciation sent to {department.name}."
StaffActivityService.log_from_request(
request,
activity_type="send",
description=f"Sent appreciation {appreciation.pk} to {recipient_type}",
content_object=appreciation,
module="appreciation",
)
AuditService.log_event(
event_type="appreciation_sent",
description=f"Appreciation {appreciation.pk} sent to {recipient_type}",
user=user,
content_object=appreciation,
metadata={"recipient_type": recipient_type},
)
return JsonResponse({"success": True, "message": message})
except Exception as e:
import logging
logging.getLogger(__name__).error(f"Error in appreciation_send_to: {e}")
return JsonResponse(
{"success": False, "error": str(_("An error occurred while sending the appreciation."))},
status=500,
)
# ============================================================================
# ACKNOWLEDGE
# ============================================================================
@login_required
@require_http_methods(["POST"])
def appreciation_acknowledge(request, pk):
"""DEPRECATED: ACKNOWLEDGED status was removed; SENT is terminal.
The URL route was removed too. This view function is kept only so that
any stale import or bookmarked URL that somehow resolves does not crash
the worker; it redirects with a clear message. Safe to delete once no
references remain.
"""
messages.error(
request,
_("Appreciation acknowledge is no longer supported — sending is the final step."),
)
return redirect("appreciation:appreciation_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def appreciation_send_dept_reminder(request, pk):
"""Send a reminder to the department that hasn't acknowledged an appreciation."""
appreciation = get_object_or_404(Appreciation, pk=pk)
user = request.user
if not (
user.is_px_admin() or user.is_hospital_admin()
or user.is_px_management() or user.is_px_employee()
):
messages.error(request, _("You don't have permission to send reminders."))
return redirect("appreciation:appreciation_detail", pk=pk)
if appreciation.status != AppreciationStatus.SENT:
messages.warning(request, _("Appreciation must be sent before sending reminders."))
return redirect("appreciation:appreciation_detail", pk=pk)
dept = appreciation.department
if not dept:
messages.error(request, _("No department assigned."))
return redirect("appreciation:appreciation_detail", pk=pk)
reminder_type = request.POST.get("reminder_type", "first")
if reminder_type == "first" and appreciation.dept_response_reminder_sent_at:
messages.warning(request, _("First reminder already sent."))
return redirect("appreciation:appreciation_detail", pk=pk)
if reminder_type == "second" and appreciation.dept_response_second_reminder_sent_at:
messages.warning(request, _("Second reminder already sent."))
return redirect("appreciation:appreciation_detail", pk=pk)
if reminder_type == "second" and not appreciation.dept_response_reminder_sent_at:
messages.warning(request, _("Please send the first reminder before the second."))
return redirect("appreciation:appreciation_detail", pk=pk)
try:
from apps.notifications.services import NotificationService, get_email_header_html
from apps.organizations.department_contacts import get_champion_and_manager
targets = get_champion_and_manager(dept)
if not targets:
messages.error(request, _(f"No champion or manager assigned to {dept.name}."))
return redirect("appreciation:appreciation_detail", pk=pk)
recipients = []
for target in targets:
email = target.get("email") or ""
if email:
recipients.append({"email": email, "name": target.get("display_name", "")})
if reminder_type == "first":
appreciation.dept_response_reminder_sent_at = timezone.now()
else:
appreciation.dept_response_second_reminder_sent_at = timezone.now()
appreciation.save(update_fields=["dept_response_reminder_sent_at", "dept_response_second_reminder_sent_at"])
from apps.appreciation.tasks import send_appreciation_reminder_email
send_appreciation_reminder_email.delay(str(appreciation.id), reminder_type, recipients)
AuditService.log_event(
event_type="appreciation_reminder_sent",
description=f"{reminder_type.capitalize()} reminder sent for appreciation {appreciation.reference_number} by {user.get_full_name()}",
content_object=appreciation,
)
messages.success(request, _(f"{reminder_type.capitalize()} reminder sent to {len(recipients)} recipient(s)."))
except Exception as e:
logger.error(f"Failed to send appreciation reminder: {e}")
messages.error(request, _("Failed to send reminder."))
return redirect("appreciation:appreciation_detail", pk=pk)
# ============================================================================
# LEADERBOARD VIEWS
# ============================================================================
@login_required
def leaderboard_view(request):
"""
Appreciation leaderboard view.
Features:
- Monthly rankings
- Hospital and department filters
- Top recipients with badges
"""
user = request.user
# Get date range
now = timezone.now()
year = int(request.GET.get('year', now.year))
month = int(request.GET.get('month', now.month))
# Build base query
queryset = AppreciationStats.objects.filter(year=year, month=month)
# Apply RBAC
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
if selected_hospital:
queryset = queryset.filter(hospital=selected_hospital)
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_px_management() or user.is_px_employee():
if user.hospital:
queryset = queryset.filter(hospital=user.hospital)
elif user.is_department_manager() and user.department:
queryset = queryset.filter(department=user.department)
elif user.is_champion() and user.department:
queryset = queryset.filter(department=user.department)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters
hospital_filter = request.GET.get('hospital')
if hospital_filter:
queryset = queryset.filter(hospital_id=hospital_filter)
department_filter = request.GET.get('department')
if department_filter:
queryset = queryset.filter(department_id=department_filter)
# Order by received count
queryset = queryset.order_by('-received_count')
# Pagination
page_size = int(request.GET.get('page_size', 50))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
departments = Department.objects.filter(status='active')
if not user.is_px_admin() and user.hospital:
departments = departments.filter(hospital=user.hospital)
# Get months for filter
months = [(i, timezone.datetime(year=year, month=i, day=1).strftime('%B')) for i in range(1, 13)]
years = range(now.year - 1, now.year + 2)
context = {
'page_obj': page_obj,
'leaderboard': page_obj.object_list,
'departments': departments,
'months': months,
'years': years,
'selected_year': year,
'selected_month': month,
'filters': request.GET,
}
return render(request, 'appreciation/leaderboard.html', context)
@login_required
def my_badges_view(request):
"""
User's badges view.
Features:
- All earned badges
- Badge details and criteria
- Progress toward next badges
"""
user = request.user
user_content_type = ContentType.objects.get_for_model(user)
# Get user's badges
queryset = UserBadge.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id
).select_related('badge').order_by('-earned_at')
# Pagination
page_size = int(request.GET.get('page_size', 20))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
# Get available badges for progress tracking
available_badges = AppreciationBadge.objects.filter(is_active=True)
if not request.user.is_px_admin() and request.user.hospital:
available_badges = available_badges.filter(Q(hospital_id=request.user.hospital.id) | Q(hospital__isnull=True))
# Calculate progress for each badge
badge_progress = []
total_received = Appreciation.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id
).count()
for badge in available_badges:
earned = queryset.filter(badge=badge).exists()
progress = 0
if badge.criteria_type in ('received_count', 'received_month'):
progress = min(100, int((total_received / badge.criteria_value) * 100)) if badge.criteria_value else 0
elif badge.criteria_type == 'diverse_senders':
unique_senders = Appreciation.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id
).values('sender').distinct().count()
progress = min(100, int((unique_senders / badge.criteria_value) * 100)) if badge.criteria_value else 0
elif badge.criteria_type == 'streak_weeks':
from datetime import timedelta
now = timezone.now()
streak = 0
for i in range(badge.criteria_value):
week_start = now - timedelta(weeks=i+1)
week_end = now - timedelta(weeks=i)
if Appreciation.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id,
sent_at__gte=week_start,
sent_at__lt=week_end,
).exists():
streak += 1
else:
break
progress = min(100, int((streak / badge.criteria_value) * 100)) if badge.criteria_value else 0
badge_progress.append({
'badge': badge,
'earned': earned,
'progress': progress,
})
context = {
'page_obj': page_obj,
'badges': page_obj.object_list,
'total_received': total_received,
'badge_progress': badge_progress,
}
return render(request, 'appreciation/my_badges.html', context)
# ============================================================================
# ADMIN: CATEGORY MANAGEMENT
# ============================================================================
@login_required
def category_list(request):
"""List and manage appreciation categories"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to manage categories.")
return redirect('appreciation:appreciation_list')
# Base queryset
queryset = AppreciationCategory.objects.annotate(
appreciation_count=Count('appreciations'),
)
# Apply RBAC
if not user.is_px_admin() and user.hospital:
queryset = queryset.filter(Q(hospital_id=user.hospital.id) | Q(hospital__isnull=True))
# Search
search_query = request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name_en__icontains=search_query) |
Q(name_ar__icontains=search_query) |
Q(code__icontains=search_query)
)
# Ordering
queryset = queryset.order_by('order', 'code')
# Pagination
page_size = int(request.GET.get('page_size', 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'categories': page_obj.object_list,
}
return render(request, 'appreciation/category_list.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def category_create(request):
"""Create appreciation category"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to create categories.")
return redirect('appreciation:appreciation_list')
if request.method == 'POST':
try:
code = request.POST.get('code')
name_en = request.POST.get('name_en')
name_ar = request.POST.get('name_ar', '')
description_en = request.POST.get('description_en', '')
description_ar = request.POST.get('description_ar', '')
icon = request.POST.get('icon', 'fa-heart')
color = request.POST.get('color', '#FF5733')
order = request.POST.get('order', 0)
is_active = request.POST.get('is_active') == 'on'
# Get hospital
hospital = None
if user.is_hospital_admin() and user.hospital:
hospital = user.hospital
# Validate
if not all([code, name_en]):
messages.error(request, "Please fill in all required fields.")
return redirect('appreciation:category_create')
# Create category
AppreciationCategory.objects.create(
code=code,
name_en=name_en,
name_ar=name_ar,
description_en=description_en,
description_ar=description_ar,
icon=icon,
color=color,
order=order,
is_active=is_active,
hospital=hospital,
)
messages.success(request, "Category created successfully.")
return redirect('appreciation:category_list')
except Exception as e:
messages.error(request, f"Error creating category: {str(e)}")
return redirect('appreciation:category_create')
context = {}
return render(request, 'appreciation/admin/category_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def category_edit(request, pk):
"""Edit appreciation category"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to edit categories.")
return redirect('appreciation:appreciation_list')
category = get_object_or_404(AppreciationCategory, pk=pk)
# Check access
if not user.is_px_admin() and category.hospital != user.hospital:
messages.error(request, "You don't have permission to edit this category.")
return redirect('appreciation:category_list')
if request.method == 'POST':
try:
category.code = request.POST.get('code')
category.name_en = request.POST.get('name_en')
category.name_ar = request.POST.get('name_ar', '')
category.description_en = request.POST.get('description_en', '')
category.description_ar = request.POST.get('description_ar', '')
category.icon = request.POST.get('icon', 'fa-heart')
category.color = request.POST.get('color', '#FF5733')
category.order = request.POST.get('order', 0)
category.is_active = request.POST.get('is_active') == 'on'
category.save()
messages.success(request, "Category updated successfully.")
return redirect('appreciation:category_list')
except Exception as e:
messages.error(request, f"Error updating category: {str(e)}")
return redirect('appreciation:category_edit', pk=pk)
context = {
'category': category,
}
return render(request, 'appreciation/admin/category_form.html', context)
@login_required
@require_http_methods(["POST"])
def category_delete(request, pk):
"""Delete appreciation category"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to delete categories.")
return redirect('appreciation:appreciation_list')
category = get_object_or_404(AppreciationCategory, pk=pk)
# Check if category is in use
if Appreciation.objects.filter(category=category).exists():
messages.error(request, "Cannot delete category that is in use.")
return redirect('appreciation:category_list')
# Log audit
AuditService.log_event(
event_type='category_deleted',
description=f"Appreciation category deleted: {category.name_en}",
user=request.user,
metadata={'category_code': category.code}
)
category.delete()
messages.success(request, "Category deleted successfully.")
return redirect('appreciation:category_list')
# ============================================================================
# ADMIN: BADGE MANAGEMENT
# ============================================================================
@login_required
def badge_list(request):
"""List and manage appreciation badges"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to manage badges.")
return redirect('appreciation:appreciation_list')
# Base queryset
queryset = AppreciationBadge.objects.annotate(
earned_count=Count('earned_by'),
)
# Apply RBAC
if not user.is_px_admin() and user.hospital:
queryset = queryset.filter(Q(hospital_id=user.hospital.id) | Q(hospital__isnull=True))
# Search
search_query = request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name_en__icontains=search_query) |
Q(name_ar__icontains=search_query) |
Q(code__icontains=search_query)
)
# Ordering
queryset = queryset.order_by('order', 'code')
# Pagination
page_size = int(request.GET.get('page_size', 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get('page', 1)
page_obj = paginator.get_page(page_number)
context = {
'page_obj': page_obj,
'badges': page_obj.object_list,
}
return render(request, 'appreciation/badge_list.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def badge_create(request):
"""Create appreciation badge"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to create badges.")
return redirect('appreciation:appreciation_list')
if request.method == 'POST':
try:
code = request.POST.get('code')
name_en = request.POST.get('name_en')
name_ar = request.POST.get('name_ar', '')
description_en = request.POST.get('description_en', '')
description_ar = request.POST.get('description_ar', '')
icon = request.POST.get('icon', 'fa-award')
color = request.POST.get('color', '#FFD700')
criteria_type = request.POST.get('criteria_type', 'received_count')
criteria_value = request.POST.get('criteria_value', 5)
order = request.POST.get('order', 0)
is_active = request.POST.get('is_active') == 'on'
# Get hospital
hospital = None
if user.is_hospital_admin() and user.hospital:
hospital = user.hospital
# Validate
if not all([code, name_en, criteria_value]):
messages.error(request, "Please fill in all required fields.")
return redirect('appreciation:badge_create')
# Create badge
AppreciationBadge.objects.create(
code=code,
name_en=name_en,
name_ar=name_ar,
description_en=description_en,
description_ar=description_ar,
icon=icon,
color=color,
criteria_type=criteria_type,
criteria_value=int(criteria_value),
order=order,
is_active=is_active,
hospital=hospital,
)
messages.success(request, "Badge created successfully.")
return redirect('appreciation:badge_list')
except Exception as e:
messages.error(request, f"Error creating badge: {str(e)}")
return redirect('appreciation:badge_create')
context = {}
return render(request, 'appreciation/admin/badge_form.html', context)
@login_required
@require_http_methods(["GET", "POST"])
def badge_edit(request, pk):
"""Edit appreciation badge"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to edit badges.")
return redirect('appreciation:appreciation_list')
badge = get_object_or_404(AppreciationBadge, pk=pk)
# Check access
if not user.is_px_admin() and badge.hospital != user.hospital:
messages.error(request, "You don't have permission to edit this badge.")
return redirect('appreciation:badge_list')
if request.method == 'POST':
try:
badge.code = request.POST.get('code')
badge.name_en = request.POST.get('name_en')
badge.name_ar = request.POST.get('name_ar', '')
badge.description_en = request.POST.get('description_en', '')
badge.description_ar = request.POST.get('description_ar', '')
badge.icon = request.POST.get('icon', 'fa-award')
badge.color = request.POST.get('color', '#FFD700')
badge.criteria_type = request.POST.get('criteria_type', 'received_count')
badge.criteria_value = request.POST.get('criteria_value', 5)
badge.order = request.POST.get('order', 0)
badge.is_active = request.POST.get('is_active') == 'on'
badge.save()
messages.success(request, "Badge updated successfully.")
return redirect('appreciation:badge_list')
except Exception as e:
messages.error(request, f"Error updating badge: {str(e)}")
return redirect('appreciation:badge_edit', pk=pk)
context = {
'badge': badge,
}
return render(request, 'appreciation/admin/badge_form.html', context)
@login_required
@require_http_methods(["POST"])
def badge_delete(request, pk):
"""Delete appreciation badge"""
user = request.user
# Check permission
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to delete badges.")
return redirect('appreciation:appreciation_list')
badge = get_object_or_404(AppreciationBadge, pk=pk)
# Check if badge is in use
if UserBadge.objects.filter(badge=badge).exists():
messages.error(request, "Cannot delete badge that has been earned.")
return redirect('appreciation:badge_list')
# Log audit
AuditService.log_event(
event_type='badge_deleted',
description=f"Appreciation badge deleted: {badge.name_en}",
user=request.user,
metadata={'badge_code': badge.code}
)
badge.delete()
messages.success(request, "Badge deleted successfully.")
return redirect('appreciation:badge_list')
@login_required
@require_http_methods(["POST"])
def appreciation_restore(request, pk):
"""Restore deleted appreciation"""
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
messages.error(request, "You don't have permission to restore appreciation.")
return redirect('config:deleted_items')
appreciation = get_object_or_404(Appreciation.all_objects, pk=pk, is_deleted=True)
appreciation.restore()
messages.success(request, "Appreciation restored successfully.")
return redirect('config:deleted_items')
# ============================================================================
# AJAX/API HELPERS
# ============================================================================
@login_required
def get_users_by_hospital(request):
"""Get users for a hospital (AJAX)"""
hospital_id = request.GET.get('hospital_id')
if not hospital_id:
return JsonResponse({'users': []})
users = User.objects.filter(
hospital_id=hospital_id,
is_active=True
).values('id', 'first_name', 'last_name')
results = [
{
'id': str(u['id']),
'name': f"{u['first_name']} {u['last_name']}",
}
for u in users
]
return JsonResponse({'users': results})
@login_required
def get_staff_by_hospital(request):
"""Get staff for a hospital (AJAX)"""
hospital_id = request.GET.get('hospital_id')
if not hospital_id:
return JsonResponse({'staff': []})
staff = Staff.objects.filter(
hospital_id=hospital_id,
status='active'
).values('id', 'user__first_name', 'user__last_name')
results = [
{
'id': str(s['id']),
'name': f"{s['user__first_name']} {s['user__last_name']}",
}
for s in staff
]
return JsonResponse({'staff': results})
@login_required
def get_physicians_by_hospital(request):
"""Get physicians for a hospital (AJAX)"""
hospital_id = request.GET.get('hospital_id')
if not hospital_id:
return JsonResponse({'physicians': []})
physicians = Staff.objects.filter(
hospital_id=hospital_id,
status='active',
staff_type='physician'
).values('id', 'user__first_name', 'user__last_name')
results = [
{
'id': str(p['id']),
'name': f"{p['user__first_name']} {p['user__last_name']}",
}
for p in physicians
]
return JsonResponse({'physicians': results})
@login_required
def get_departments_by_hospital(request):
"""Get departments for a hospital (AJAX)"""
hospital_id = request.GET.get('hospital_id')
if not hospital_id:
return JsonResponse({'departments': []})
departments = Department.objects.filter(
hospital_id=hospital_id,
status='active'
).values('id', 'name', 'name_ar')
results = [
{
'id': str(d['id']),
'name': d['name'],
}
for d in departments
]
return JsonResponse({'departments': results})
@login_required
def appreciation_summary_ajax(request):
"""Get appreciation summary for current user (AJAX)"""
user = request.user
user_content_type = ContentType.objects.get_for_model(user)
now = timezone.now()
current_year = now.year
current_month = now.month
summary = {
'total_received': Appreciation.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id
).count(),
'total_sent': Appreciation.objects.filter(sender=user).count(),
'this_month_received': Appreciation.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id,
sent_at__year=current_year,
sent_at__month=current_month
).count(),
'this_month_sent': Appreciation.objects.filter(
sender=user,
sent_at__year=current_year,
sent_at__month=current_month
).count(),
'badges_earned': UserBadge.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id
).count(),
}
# Get hospital rank
if user.hospital:
stats = AppreciationStats.objects.filter(
recipient_content_type=user_content_type,
recipient_object_id=user.id,
year=current_year,
month=current_month
).first()
summary['hospital_rank'] = stats.hospital_rank if stats else None
return JsonResponse(summary)
@csrf_exempt
@require_http_methods(["POST"])
def public_appreciation_submit(request):
import json
import logging
logger = logging.getLogger(__name__)
client_ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR', '')
cache_key = f"appreciation_rate:{client_ip}"
from django.core.cache import cache
if cache.get(cache_key, 0) >= 5:
return JsonResponse({"success": False, "message": "Too many requests. Please try again later."}, status=429)
cache.set(cache_key, cache.get(cache_key, 0) + 1, 300)
try:
try:
data = json.loads(request.body) if request.content_type == 'application/json' else request.POST
except json.JSONDecodeError:
data = request.POST
contact_name = data.get("contact_name", "").strip()
contact_phone = data.get("contact_phone", "").strip()
message = data.get("message", "").strip()
hospital_id = data.get("hospital", "")
staff_name = data.get("staff_name", "").strip()
department_id = data.get("department", "").strip()
section_id = data.get("section", "").strip()
if not contact_name or not contact_phone or not message:
return JsonResponse({"success": False, "message": "Name, phone, and message are required."}, status=400)
from apps.core.validators import validate_saudi_phone
from django.core.exceptions import ValidationError
try:
validate_saudi_phone(contact_phone)
except ValidationError:
return JsonResponse(
{"success": False, "message": "Please enter a valid Saudi mobile number (e.g. 05XXXXXXXX or +9665XXXXXXXX)."},
status=400,
)
if not hospital_id:
return JsonResponse({"success": False, "message": "Please select a hospital."}, status=400)
try:
hospital = Hospital.objects.get(id=hospital_id)
except Hospital.DoesNotExist:
return JsonResponse({"success": False, "message": "Invalid hospital."}, status=400)
from apps.organizations.models import Department, Section, OrgSubSection
department = Department.objects.filter(id=department_id).first() if department_id else None
section = Section.objects.filter(id=section_id).first() if section_id else None
appreciation = Appreciation(
hospital=hospital,
department=department,
section=section,
category=None,
message_en=message,
message_ar="",
is_anonymous=False,
status=AppreciationStatus.DRAFT,
visibility=AppreciationVisibility.PUBLIC,
metadata={
"source": "public_form",
"submitted_by_name": contact_name,
"submitted_by_phone": contact_phone,
"staff_name_mentioned": staff_name,
},
)
appreciation.save()
try:
from apps.complaints.tasks import notify_staff_new_item
notify_staff_new_item.delay("appreciation", str(appreciation.id))
except Exception:
pass
AuditService.log_event(
event_type="public_appreciation_submitted",
description=f"Public appreciation submitted by {contact_name}",
content_object=appreciation,
metadata={"hospital": str(hospital.id), "staff_mentioned": staff_name},
)
return JsonResponse({"success": True, "reference": appreciation.reference_number})
except Exception as e:
logger.exception("ERROR in public_appreciation_submit")
return JsonResponse({"success": False, "message": str(e)}, status=500)
def _appreciation_pdf_context(appreciation):
"""Build the common context for the appreciation letter & certificate PDFs."""
hospital = appreciation.hospital
hospital_name = hospital.name if hospital else ""
hospital_name_ar = getattr(hospital, "name_ar", "") if hospital else ""
recipient = appreciation.recipient
recipient_name = appreciation.get_recipient_name() or ""
department_name = appreciation.department.get_localized_name() if appreciation.department else ""
recipient_title = ""
if recipient is not None:
getter = getattr(recipient, "get_localized_job_title", None)
if callable(getter):
recipient_title = getter() or ""
recipient_title = recipient_title or getattr(recipient, "job_title", "") or ""
meta = appreciation.metadata or {}
is_patient = meta.get("source") == "public_form" or bool(meta.get("submitted_by_name"))
patient_name = (meta.get("submitted_by_name") or "").strip()
# Signer: sender (unless anonymous), else the activating officer, else generic PX team.
signer = appreciation.sender if (appreciation.sender and not appreciation.is_anonymous) else appreciation.activated_by
signer_name = _("Al Hammadi Patient Experience")
signer_role = _("Patient Experience Team")
if signer:
signer_name = signer.get_full_name() or signer_name
role_names = signer.get_role_names() if hasattr(signer, "get_role_names") else []
if role_names:
signer_role = ", ".join(role_names)
date_dt = appreciation.sent_at or appreciation.activated_at or appreciation.created_at
return {
"hospital_name": hospital_name,
"hospital_name_ar": hospital_name_ar,
"recipient_name": recipient_name,
"recipient_title": recipient_title,
"department_name": department_name,
"is_patient": is_patient,
"patient_name": patient_name,
"message_en": appreciation.message_en or "",
"message_ar": appreciation.message_ar or "",
"has_ar": bool((appreciation.message_ar or "").strip()),
"signer_name": signer_name,
"signer_role": signer_role,
"date_dt": date_dt,
"reference": appreciation.reference_number or "",
}
def _render_appreciation_pdf_bytes(template_name, context, obj=None):
"""Render a PDF template via WeasyPrint and return the raw PDF bytes.
When ``obj`` is provided, its hospital letterhead is injected into the context
as ``letterhead_path`` (data URI) via the shared ``pdf_utils`` helper.
Used by both the PDF download views (wraps bytes in HttpResponse) and the
notification task (attaches bytes to email).
"""
from django.conf import settings
from django.template.loader import render_to_string
from weasyprint import HTML
full_context = dict(context)
if obj is not None:
from apps.core.pdf_utils import _get_letterhead_data_uri
full_context.setdefault("letterhead_path", _get_letterhead_data_uri(obj))
html_string = render_to_string(template_name, full_context)
return HTML(string=html_string, base_url=str(settings.BASE_DIR / "static")).write_pdf()
def _appreciation_pdf_response(template_name, context, filename, obj=None):
"""Render a PDF template via WeasyPrint and return it as an attachment response."""
from django.http import HttpResponse
pdf_file = _render_appreciation_pdf_bytes(template_name, context, obj=obj)
response = HttpResponse(pdf_file, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
def _check_appreciation_pdf_access(request, appreciation):
"""Shared permission + activation gate for the appreciation PDF views."""
user = request.user
allowed = (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
or (user.hospital_id and appreciation.hospital_id == user.hospital_id)
)
if not allowed:
messages.error(request, _("You don't have permission to view this appreciation."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
if not appreciation.activated_at:
messages.error(request, _("This appreciation must be activated before generating a document."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
if not appreciation.recipient:
messages.error(request, _("Select a recipient before generating a document."))
return redirect("appreciation:appreciation_detail", pk=appreciation.pk)
return None
@login_required
def appreciation_letter(request, pk):
"""Everyday Letter of Appreciation (portrait, message-forward, sender-signed)."""
obj = get_object_or_404(Appreciation, id=pk)
denied = _check_appreciation_pdf_access(request, obj)
if denied:
return denied
ctx = _appreciation_pdf_context(obj)
return _appreciation_pdf_response(
"appreciation/appreciation_letter.html",
ctx,
f"appreciation_letter_{obj.reference_number}.pdf",
obj=obj,
)
@login_required
def appreciation_certificate(request, pk):
"""Formal Certificate of Appreciation (landscape, leadership-signed)."""
obj = get_object_or_404(Appreciation, id=pk)
denied = _check_appreciation_pdf_access(request, obj)
if denied:
return denied
ctx = _appreciation_pdf_context(obj)
hospital = obj.hospital
ctx["ceo_name"] = hospital.ceo.get_full_name() if hospital and hospital.ceo else ""
ctx["medical_director_name"] = (
hospital.medical_director.get_full_name() if hospital and hospital.medical_director else ""
)
ctx["hospital_initials"] = (
"".join(w[0] for w in (hospital.name or "").split()[:2]).upper() if hospital and hospital.name else "HH"
)
return _appreciation_pdf_response(
"appreciation/appreciation_certificate.html",
ctx,
f"appreciation_certificate_{obj.reference_number}.pdf",
)