""" 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__in=[AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED]).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"""
""", 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): """Acknowledge appreciation""" appreciation = get_object_or_404(Appreciation, pk=pk) # Check if user is recipient user_content_type = ContentType.objects.get_for_model(request.user) if not ( appreciation.recipient_content_type == user_content_type and appreciation.recipient_object_id == request.user.id ): messages.error(request, "You can only acknowledge appreciations sent to you.") return redirect('appreciation:appreciation_detail', pk=pk) if appreciation.status != AppreciationStatus.SENT: messages.error(request, "This appreciation cannot be acknowledged in its current status.") return redirect('appreciation:appreciation_detail', pk=pk) # Acknowledge appreciation.acknowledge() messages.success(request, "Appreciation acknowledged successfully.") 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 not in (AppreciationStatus.SENT, AppreciationStatus.ACKNOWLEDGED): 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", )