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'.
1623 lines
61 KiB
Python
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",
|
|
)
|