""" 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"""
{get_email_header_html()}

Appreciation Shared With You

An appreciation #{appreciation.reference_number} has been shared with you.

Message: {appreciation.message_en or 'N/A'}

{f'

Note: {note}

' if note else ''}

View Appreciation

""", 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", )