Drops the dead AI_ANALYZED status (a prior data migration already converted existing rows; the choice was never removed from the model) and the unreachable ACKNOWLEDGED status (is_recipient was hardcoded False so the acknowledge button never rendered). Changes: - AppreciationStatus: DRAFT, ACTIVATED, SENT only. SENT is terminal. - VALID_APPRECIATION_TRANSITIONS: DRAFT→ACTIVATED→SENT, SENT terminal. - Removed the model.acknowledge() method. - DRF viewset acknowledge action: returns 410 Gone (deprecated). - UI appreciation_acknowledge view: redirects with deprecation message. - Removed URL routes: appreciation_acknowledge, appreciation_send_dept_reminder. - appreciation_send_dept_reminder status check no longer references ACKNOWLEDGED. - appreciation_list stats + status filter + badge: dropped ai_analyzed/acknowledged. - appreciation_detail status badge + sent banner: dropped acknowledged references. - Migration 0010_drop_acknowledged_status: AlterField on status choices. AI analysis still runs on activation and is stored in ai_analysis JSON; it just no longer has its own status. Badges and leaderboard fire at SENT. 6 tests lock in the new lifecycle and removed URLs.
1618 lines
61 KiB
Python
1618 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", "")
|
|
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",
|
|
)
|