HH/apps/feedback/views.py
2026-07-11 19:24:28 +03:00

1341 lines
49 KiB
Python

"""
Feedback views - Server-rendered templates for feedback management
"""
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Q, Count, Avg
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from django.core.cache import cache
from apps.accounts.models import User
from apps.accounts.services import StaffActivityService
from apps.core.services import AuditService
from apps.notifications.services import NotificationService
from apps.organizations.models import Department, Hospital, Patient, Staff
from .models import (
Feedback,
FeedbackAttachment,
FeedbackResponse,
FeedbackStatus,
FeedbackType,
FeedbackCategory,
VALID_FEEDBACK_TRANSITIONS,
)
from .forms import (
FeedbackForm,
FeedbackResponseForm,
FeedbackStatusChangeForm,
)
import logging
logger = logging.getLogger(__name__)
@login_required
def feedback_list(request):
"""
Feedback list view with advanced filters and pagination.
Features:
- Server-side pagination
- Advanced filters (status, type, sentiment, category, hospital, etc.)
- Search by title, message, patient name
- Statistics dashboard
- Export capability
"""
# Base queryset with optimizations
queryset = Feedback.objects.select_related(
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
).filter(is_deleted=False)
# Apply RBAC filters
user = request.user
# Get selected hospital for PX Admins (from middleware)
selected_hospital = getattr(request, "tenant_hospital", None)
if user.is_px_admin():
# PX Admins see all, but filter by selected hospital if set
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_department_manager() and user.department:
queryset = queryset.filter(department=user.department)
elif user.hospital:
queryset = queryset.filter(hospital=user.hospital)
else:
queryset = queryset.none()
# Apply filters from request
feedback_type_filter = request.GET.get("feedback_type")
if feedback_type_filter:
queryset = queryset.filter(feedback_type=feedback_type_filter)
status_filter = request.GET.get("status")
if status_filter:
queryset = queryset.filter(status=status_filter)
category_filter = request.GET.get("category")
if category_filter:
queryset = queryset.filter(category=category_filter)
sentiment_filter = request.GET.get("sentiment")
if sentiment_filter:
queryset = queryset.filter(sentiment=sentiment_filter)
priority_filter = request.GET.get("priority")
if priority_filter:
queryset = queryset.filter(priority=priority_filter)
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)
staff_filter = request.GET.get("staff")
if staff_filter:
queryset = queryset.filter(staff_id=staff_filter)
assigned_to_filter = request.GET.get("assigned_to")
if assigned_to_filter:
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
rating_min = request.GET.get("rating_min")
if rating_min:
queryset = queryset.filter(rating__gte=rating_min)
rating_max = request.GET.get("rating_max")
if rating_max:
queryset = queryset.filter(rating__lte=rating_max)
is_featured = request.GET.get("is_featured")
if is_featured == "true":
queryset = queryset.filter(is_featured=True)
requires_follow_up = request.GET.get("requires_follow_up")
if requires_follow_up == "true":
queryset = queryset.filter(requires_follow_up=True)
# Search
search_query = request.GET.get("search")
if search_query:
queryset = queryset.filter(
Q(title__icontains=search_query)
| Q(message__icontains=search_query)
| Q(patient__mrn__icontains=search_query)
| Q(patient__first_name__icontains=search_query)
| Q(patient__last_name__icontains=search_query)
| Q(contact_name__icontains=search_query)
)
# Date range filters
date_from = request.GET.get("date_from")
if date_from:
queryset = queryset.filter(created_at__gte=date_from)
date_to = request.GET.get("date_to")
if date_to:
queryset = queryset.filter(created_at__lte=date_to)
ALLOWED_ORDER_BY = {"-created_at", "created_at", "-updated_at", "updated_at", "-title", "title", "-rating", "rating"}
order_by = request.GET.get("order_by", "-created_at")
if order_by not in ALLOWED_ORDER_BY:
order_by = "-created_at"
queryset = queryset.order_by(order_by)
page_size = min(int(request.GET.get("page_size", 25)), 100)
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 assignable users
from apps.core.utils import get_assignable_users
_hospital = request.tenant_hospital if (user.is_px_admin() and hasattr(request, "tenant_hospital") and request.tenant_hospital) else user.hospital
assignable_users = get_assignable_users(_hospital)
# Statistics
stats = {
"total": queryset.count(),
"submitted": queryset.filter(status=FeedbackStatus.SUBMITTED).count(),
"reviewed": queryset.filter(status=FeedbackStatus.REVIEWED).count(),
"acknowledged": queryset.filter(status=FeedbackStatus.ACKNOWLEDGED).count(),
"compliments": queryset.filter(feedback_type=FeedbackType.COMPLIMENT).count(),
"suggestions": queryset.filter(feedback_type=FeedbackType.SUGGESTION).count(),
"avg_rating": queryset.aggregate(Avg("rating"))["rating__avg"] or 0,
"positive": queryset.filter(sentiment="positive").count(),
"negative": queryset.filter(sentiment="negative").count(),
}
context = {
"page_obj": page_obj,
"feedbacks": page_obj.object_list,
"stats": stats,
"departments": departments,
"assignable_users": assignable_users,
"status_choices": FeedbackStatus.choices,
"type_choices": FeedbackType.choices,
"category_choices": FeedbackCategory.choices,
"filters": request.GET,
}
return render(request, "feedback/feedback_list.html", context)
@login_required
def feedback_detail(request, pk):
"""
Feedback detail view with timeline, attachments, and actions.
Features:
- Full feedback details
- Timeline of all responses
- Attachments management
- Workflow actions (assign, status change, add response)
"""
feedback = get_object_or_404(
Feedback.objects.select_related(
"patient", "hospital", "department", "staff", "assigned_to", "reviewed_by", "acknowledged_by", "closed_by"
).prefetch_related("attachments", "responses__created_by"),
pk=pk,
is_deleted=False,
)
# Check access
user = request.user
if not user.is_px_admin():
if user.is_hospital_admin() and feedback.hospital != user.hospital:
messages.error(request, "You don't have permission to view this suggestion.")
return redirect("feedback:feedback_list")
elif user.is_department_manager() and feedback.department != user.department:
messages.error(request, "You don't have permission to view this suggestion.")
return redirect("feedback:feedback_list")
elif user.hospital and feedback.hospital != user.hospital:
messages.error(request, "You don't have permission to view this suggestion.")
return redirect("feedback:feedback_list")
# Get timeline (responses)
timeline = feedback.responses.all().order_by("-created_at")
# Get attachments
attachments = feedback.attachments.all().order_by("-created_at")
# Get assignable users
from apps.core.utils import get_assignable_users
assignable_users = get_assignable_users(feedback.hospital)
from apps.rca.models import RootCauseAnalysis as RCA
from django.contrib.contenttypes.models import ContentType as CT
feedback_ct = CT.objects.get_for_model(Feedback)
linked_rcas = RCA.objects.filter(content_type=feedback_ct, object_id=feedback.pk, is_deleted=False).select_related(
"assigned_to", "created_by"
)
from django.contrib.contenttypes.models import ContentType
feedback_ct = ContentType.objects.get_for_model(Feedback)
generic_notes = feedback.notes.select_related("created_by").all()
context = {
"feedback": feedback,
"timeline": timeline,
"attachments": attachments,
"assignable_users": assignable_users,
"departments": Department.objects.filter(
hospital=feedback.hospital, status="active"
).order_by("name"),
"status_choices": FeedbackStatus.choices,
"can_edit": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"can_admin": user.is_px_admin() or user.is_hospital_admin(),
"can_send_to_department": (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
),
"linked_rcas": linked_rcas,
"content_type_id": feedback_ct.pk,
"object_id": feedback.pk,
"notes": generic_notes,
"notes_count": generic_notes.count(),
}
# Workflow stepper
_fb_steps = [
{"label": "Submitted", "icon": "plus-circle", "done": True},
{"label": "Assigned", "icon": "user-plus", "done": feedback.assigned_to is not None},
{"label": "Reviewed", "icon": "eye", "done": feedback.status in ("reviewed", "acknowledged", "closed")},
{"label": "Acknowledged", "icon": "thumbs-up", "done": feedback.status in ("acknowledged", "closed")},
{"label": "Closed", "icon": "check-circle-2", "done": feedback.status == "closed"},
]
_fb_next = next((i for i, s in enumerate(_fb_steps) if not s["done"]), len(_fb_steps))
if _fb_next < len(_fb_steps):
_fb_steps[_fb_next]["current"] = True
context["workflow_steps"] = _fb_steps
return render(request, "feedback/feedback_detail.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_create(request):
from apps.organizations.models import LegacyLocation, LegacyMainSection, LegacySubSection, Hospital
from apps.feedback.models import FeedbackType, FeedbackCategory, FeedbackStatus
communication_request = None
comm_req_id_get = request.GET.get("comm_req")
prefill = {}
if comm_req_id_get:
try:
from apps.px_sources.models import CommunicationRequest
cr_data = CommunicationRequest.get_initial_data(comm_req_id_get)
communication_request = cr_data["communication_request"]
prefill = cr_data["initial"]
except Exception:
pass
if request.method == "POST":
contact_name = request.POST.get("contact_name", "").strip()
contact_phone = request.POST.get("contact_phone", "").strip()
message = request.POST.get("message", "").strip()
hospital_id = request.POST.get("hospital", "")
title = request.POST.get("title", message[:100]).strip()
errors = []
if not contact_name:
errors.append("Name is required")
if not contact_phone:
errors.append("Phone is required")
if not message:
errors.append("Message is required")
if not hospital_id:
errors.append("Hospital is required")
if errors:
for e in errors:
messages.error(request, e)
else:
try:
hospital = Hospital.objects.get(id=hospital_id)
# Auto-link patient by phone (local first, then HIS). Never blocks creation.
from apps.organizations.patient_lookup import find_or_link_patient
linked_patient = find_or_link_patient(phone=contact_phone, hospital=hospital)
feedback = Feedback(
patient=linked_patient,
hospital=hospital,
feedback_type=FeedbackType.SUGGESTION,
title=title,
message=message,
category=FeedbackCategory.OTHER,
contact_name=contact_name,
contact_phone=contact_phone,
is_anonymous=False,
status=FeedbackStatus.SUBMITTED,
)
feedback.save()
comm_req_id = request.POST.get("comm_req")
if comm_req_id:
try:
from apps.px_sources.models import CommunicationRequest
cr = CommunicationRequest.objects.get(pk=comm_req_id)
cr.link_to_record(feedback)
except Exception:
pass
from apps.feedback.tasks import analyze_suggestion_with_ai
from apps.complaints.tasks import notify_staff_new_item
analyze_suggestion_with_ai.delay(str(feedback.id))
notify_staff_new_item.delay("suggestion", str(feedback.id))
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=f"Suggestion submitted by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True,
)
AuditService.log_event(
event_type="suggestion_created",
description=f"Suggestion created: {title}",
user=request.user,
content_object=feedback,
metadata={"source": "internal_form"},
)
StaffActivityService.log_from_request(
request,
activity_type="create",
description=f"Created suggestion {feedback.id}",
content_object=feedback,
module="feedback",
)
messages.success(request, f"Suggestion #{feedback.id} created successfully.")
return redirect("feedback:feedback_detail", pk=feedback.id)
except Exception as e:
messages.error(request, f"Error creating suggestion: {str(e)}")
hospitals = Hospital.objects.filter(status="active").order_by("name")
context = {
"hospitals": hospitals,
"is_create": True,
"communication_request": communication_request,
"prefill": prefill,
}
return render(request, "feedback/feedback_form.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_update(request, pk):
"""Update existing feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to edit this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
if request.method == "POST":
form = FeedbackForm(request.POST, instance=feedback, request=request)
if form.is_valid():
try:
feedback = form.save()
# Create update response
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=f"Suggestion updated by {request.user.get_full_name()}",
created_by=request.user,
is_internal=True,
)
# Log audit
AuditService.log_event(
event_type="suggestion_updated",
description=f"Suggestion updated: {feedback.title}",
user=request.user,
content_object=feedback,
)
StaffActivityService.log_from_request(
request,
activity_type="update",
description=f"Updated suggestion {feedback.id}",
content_object=feedback,
module="feedback",
)
messages.success(request, "Suggestion updated successfully.")
return redirect("feedback:feedback_detail", pk=feedback.id)
except Exception as e:
messages.error(request, f"Error updating suggestion: {str(e)}")
else:
messages.error(request, "Please correct the errors below.")
else:
form = FeedbackForm(instance=feedback, request=request)
# Get patients for selection
patients = Patient.objects.filter(status="active")
if request.user.hospital:
patients = patients.filter(primary_hospital=request.user.hospital)
context = {
"form": form,
"feedback": feedback,
"patients": patients,
"is_create": False,
}
return render(request, "feedback/feedback_form.html", context)
@login_required
@require_http_methods(["GET", "POST"])
def feedback_delete(request, pk):
"""Soft delete feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to delete this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
if request.method == "POST":
try:
feedback.soft_delete(user=request.user)
# Log audit
AuditService.log_event(
event_type="suggestion_deleted",
description=f"Suggestion deleted: {feedback.title}",
user=request.user,
content_object=feedback,
)
StaffActivityService.log_from_request(
request,
activity_type="delete",
description=f"Deleted suggestion {feedback.id}",
content_object=feedback,
module="feedback",
)
messages.success(request, "Suggestion deleted successfully.")
return redirect("feedback:feedback_list")
except Exception as e:
messages.error(request, f"Error deleting suggestion: {str(e)}")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
def comment_import_list(request):
"""
Step 0 — Comment Imports list view.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import CommentImport
qs = CommentImport.objects.select_related("hospital", "imported_by")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
qs = qs.order_by("-year", "-month")
return render(request, "feedback/comment_import_list.html", {"imports": qs})
@login_required
def comment_list(request):
"""
Step 1 — Classified Patient Comments list view with filters.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import PatientComment
qs = PatientComment.objects.select_related("hospital")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
classification = request.GET.get("classification")
sub_category = request.GET.get("sub_category")
sentiment = request.GET.get("sentiment")
year = request.GET.get("year")
month = request.GET.get("month")
source_category = request.GET.get("source_category")
if classification:
qs = qs.filter(classification=classification)
if sub_category:
qs = qs.filter(sub_category=sub_category)
if sentiment:
qs = qs.filter(sentiment=sentiment)
if year:
qs = qs.filter(year=int(year))
if month:
qs = qs.filter(month=int(month))
if source_category:
qs = qs.filter(source_category=source_category)
if not request.GET.get("classified_only"):
qs = qs.filter(is_classified=True)
qs = qs.order_by("-year", "-month", "-serial_number")
from django.core.paginator import Paginator
paginator = Paginator(qs, 50)
page = request.GET.get("page", 1)
page_obj = paginator.get_page(page)
from .models import CommentClassification, CommentSubCategory, CommentSourceCategory, SentimentChoices
context = {
"page_obj": page_obj,
"classifications": CommentClassification.choices,
"sub_categories": CommentSubCategory.choices,
"sentiments": SentimentChoices.choices,
"source_categories": CommentSourceCategory.choices,
"years_range": range(2024, timezone.now().year + 1),
"months_range": range(1, 13),
"selected_classification": classification,
"selected_sub_category": sub_category,
"selected_sentiment": sentiment,
"selected_year": year,
"selected_month": month,
"selected_source_category": source_category,
}
return render(request, "feedback/comment_list.html", context)
@login_required
def action_plan_list(request):
"""
Step 5 — Comment Action Plan tracking view.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import CommentActionPlan
qs = CommentActionPlan.objects.select_related("hospital", "department")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
status_filter = request.GET.get("status")
year = request.GET.get("year")
if status_filter:
qs = qs.filter(status=status_filter)
if year:
qs = qs.filter(year=int(year))
qs = qs.order_by("department_label", "problem_number")
context = {
"action_plans": qs,
"selected_status": status_filter,
"selected_year": year,
"years_range": range(2024, timezone.now().year + 1),
}
return render(request, "feedback/action_plan_list.html", context)
@login_required
def export_comments_step1(request):
"""
Step 1 — Export classified comments to Excel.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import PatientComment
from .export_utils import export_classified_comments
qs = PatientComment.objects.filter(is_classified=True).select_related("hospital")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
year = request.GET.get("year")
month = request.GET.get("month")
if year:
qs = qs.filter(year=int(year))
if month:
qs = qs.filter(month=int(month))
return export_classified_comments(qs)
@login_required
def export_comments_step2(request):
"""
Step 2 — Export filtered comments by department to Excel.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import PatientComment
from .export_utils import export_filtered_comments_by_dept
qs = PatientComment.objects.filter(is_classified=True).select_related("hospital")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
year = request.GET.get("year")
if year:
qs = qs.filter(year=int(year))
return export_filtered_comments_by_dept(qs)
@login_required
def export_action_plans(request):
"""
Step 3/5 — Export action plans to Excel.
"""
from django.core.exceptions import PermissionDenied
if not (request.user.is_px_admin() or request.user.is_hospital_admin()):
raise PermissionDenied
from .models import CommentActionPlan
from .export_utils import export_action_plans
qs = CommentActionPlan.objects.select_related("hospital", "department")
if request.user.is_hospital_admin() and request.user.hospital:
qs = qs.filter(hospital=request.user.hospital)
elif request.user.is_px_admin() and request.tenant_hospital:
qs = qs.filter(hospital=request.tenant_hospital)
return export_action_plans(qs)
@login_required
@require_http_methods(["POST"])
def feedback_assign(request, pk):
"""Assign feedback to user"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
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 assign this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
user_id = request.POST.get("user_id")
note = request.POST.get("note", "")
if not user_id:
messages.error(request, "Please select a user to assign.")
return redirect("feedback:feedback_detail", pk=pk)
try:
assignee = User.objects.get(id=user_id)
feedback.assigned_to = assignee
feedback.assigned_at = timezone.now()
feedback.save(update_fields=["assigned_to", "assigned_at"])
# Create response
message = f"Assigned to {assignee.get_full_name()}"
if note:
message += f"\nNote: {note}"
FeedbackResponse.objects.create(
feedback=feedback, response_type="assignment", message=message, created_by=request.user, is_internal=True
)
# Log audit
AuditService.log_event(
event_type="assignment",
description=f"Suggestion assigned to {assignee.get_full_name()}",
user=request.user,
content_object=feedback,
)
messages.success(request, f"Suggestion assigned to {assignee.get_full_name()}.")
except User.DoesNotExist:
messages.error(request, "User not found.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_change_status(request, pk):
"""Change feedback status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
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 change this suggestion's status.")
return redirect("feedback:feedback_detail", pk=pk)
new_status = request.POST.get("status")
note = request.POST.get("note", "")
if not new_status:
messages.error(request, "Please select a status.")
return redirect("feedback:feedback_detail", pk=pk)
valid_status_values = [s[0] for s in FeedbackStatus.choices]
if new_status not in valid_status_values:
messages.error(request, "Invalid status value.")
return redirect("feedback:feedback_detail", pk=pk)
old_status = feedback.status
if old_status == new_status:
messages.info(request, "Suggestion is already in this status.")
return redirect("feedback:feedback_detail", pk=pk)
allowed = VALID_FEEDBACK_TRANSITIONS.get(old_status, [])
if new_status not in allowed:
messages.error(
request,
f"Cannot change status from {old_status} to {new_status}. Allowed transitions: {', '.join(allowed)}.",
)
return redirect("feedback:feedback_detail", pk=pk)
feedback.status = new_status
if new_status == FeedbackStatus.REVIEWED:
feedback.reviewed_at = timezone.now()
feedback.reviewed_by = request.user
elif new_status == FeedbackStatus.ACKNOWLEDGED:
feedback.acknowledged_at = timezone.now()
feedback.acknowledged_by = request.user
elif new_status == FeedbackStatus.CLOSED:
feedback.closed_at = timezone.now()
feedback.closed_by = request.user
elif new_status == FeedbackStatus.REOPENED:
feedback.closed_at = None
feedback.closed_by = None
feedback.save()
# Create response
message = note or f"Status changed from {old_status} to {new_status}"
FeedbackResponse.objects.create(
feedback=feedback,
response_type="status_change",
message=message,
created_by=request.user,
old_status=old_status,
new_status=new_status,
is_internal=True,
)
# Log audit
AuditService.log_event(
event_type="status_change",
description=f"Suggestion status changed from {old_status} to {new_status}",
user=request.user,
content_object=feedback,
metadata={"old_status": old_status, "new_status": new_status},
)
# Notify the suggester on acknowledge/close (closed-loop communication).
# Only for suggestions with a patient-facing contact channel; internal /
# source-user submissions have no patient to notify.
if new_status in (FeedbackStatus.ACKNOWLEDGED, FeedbackStatus.CLOSED) and feedback.contact_phone:
try:
label = "acknowledged" if new_status == FeedbackStatus.ACKNOWLEDGED else "closed"
NotificationService.send_sms(
feedback.contact_phone,
f"Your suggestion (ref {feedback.reference_number}) has been {label}. "
f"Thank you for helping us improve.",
related_object=feedback,
)
except Exception:
logger.exception("Failed to send suggestion status-change SMS")
messages.success(request, f"Suggestion status changed to {new_status}.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_add_response(request, pk):
"""Add response to feedback"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin() or user.is_px_management()
or user.is_department_manager()):
messages.error(request, "You don't have permission to add responses to this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
response_type = request.POST.get("response_type", "response")
message = request.POST.get("message")
is_internal = request.POST.get("is_internal") == "on"
if not message:
messages.error(request, "Please enter a response message.")
return redirect("feedback:feedback_detail", pk=pk)
# Create response
FeedbackResponse.objects.create(
feedback=feedback,
response_type=response_type,
message=message,
created_by=request.user,
is_internal=is_internal,
)
messages.success(request, "Response added successfully.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_toggle_featured(request, pk):
"""Toggle featured status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to feature this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
feedback.is_featured = not feedback.is_featured
feedback.save(update_fields=["is_featured"])
status = "featured" if feedback.is_featured else "unfeatured"
messages.success(request, f"Suggestion {status} successfully.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_toggle_follow_up(request, pk):
"""Toggle follow-up required status"""
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
# Check permission
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to modify this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
feedback.requires_follow_up = not feedback.requires_follow_up
feedback.save(update_fields=["requires_follow_up"])
status = "marked for follow-up" if feedback.requires_follow_up else "unmarked for follow-up"
messages.success(request, f"Suggestion {status} successfully.")
return redirect("feedback:feedback_detail", pk=pk)
@csrf_exempt
@require_http_methods(["POST"])
def public_suggestion_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"suggestion_rate:{client_ip}"
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()
title = data.get("title", message[:100]).strip()
hospital_id = data.get("hospital", "")
category = data.get("category", "general")
location_id = data.get("location", "").strip()
main_section_id = data.get("main_section", "").strip()
subsection_id = data.get("subsection", "").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)
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)
category_map = {
"general": FeedbackCategory.OTHER,
"clinical_care": FeedbackCategory.CLINICAL_CARE,
"facility": FeedbackCategory.FACILITY,
"staff_service": FeedbackCategory.STAFF_SERVICE,
"communication": FeedbackCategory.COMMUNICATION,
"technology": FeedbackCategory.TECHNOLOGY,
"food_service": FeedbackCategory.FOOD_SERVICE,
"appointment": FeedbackCategory.APPOINTMENT,
"other": FeedbackCategory.OTHER,
}
# Auto-link patient by phone (local first, then HIS) — moved to background task
# to avoid blocking the response when the HIS endpoint is slow.
feedback = Feedback(
patient=None,
hospital=hospital,
feedback_type=FeedbackType.SUGGESTION,
title=title,
message=message,
category=category_map.get(category, FeedbackCategory.OTHER),
contact_name=contact_name,
contact_phone=contact_phone,
is_anonymous=False,
status=FeedbackStatus.SUBMITTED,
metadata={
"source": "public_suggestion_form",
"suggestion_area": category,
},
)
feedback.save()
# Acknowledge receipt to the suggester (closed-loop communication).
if contact_phone:
try:
NotificationService.send_sms(
contact_phone,
f"Thank you for your suggestion (ref {feedback.reference_number}). "
f"We've received it and our team will review it.",
related_object=feedback,
)
except Exception:
logger.exception("Failed to send suggestion acknowledgement SMS")
try:
from apps.feedback.tasks import analyze_suggestion_with_ai, link_feedback_patient
from apps.complaints.tasks import notify_staff_new_item
analyze_suggestion_with_ai.delay(str(feedback.id))
notify_staff_new_item.delay("suggestion", str(feedback.id))
link_feedback_patient.delay(str(feedback.id), phone=contact_phone)
except Exception:
pass
AuditService.log_event(
event_type="public_suggestion_submitted",
description=f"Public suggestion submitted by {contact_name}: {title}",
content_object=feedback,
metadata={"hospital": str(hospital.id), "category": category},
)
return JsonResponse({"success": True, "reference": f"SG-{str(feedback.pk)[:8]}"})
except Exception as e:
logger.exception("ERROR in public_suggestion_submit")
return JsonResponse({"success": False, "message": str(e)}, status=500)
@login_required
@require_http_methods(["POST"])
def feedback_create_action(request, pk):
from django.contrib.contenttypes.models import ContentType
from apps.px_action_center.models import PXAction, ActionSource
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
user = request.user
if not (user.is_px_admin() or user.is_hospital_admin()):
messages.error(request, "You don't have permission to create actions from suggestions.")
return redirect("feedback:feedback_detail", pk=pk)
action_title = request.POST.get("action_title", "").strip()
action_description = request.POST.get("action_description", "").strip()
action_category = request.POST.get("action_category", "other")
action_priority = request.POST.get("action_priority", "medium")
if not action_title:
action_title = f"Action from Suggestion: {feedback.title}"
if not action_description:
ai = feedback.ai_analysis
action_description = ai.get("description_en", "") or ai.get("short_description_en", feedback.message[:500])
feedback_ct = ContentType.objects.get_for_model(Feedback)
action = PXAction.objects.create(
source_type="suggestion",
content_type=feedback_ct,
object_id=feedback.id,
title=action_title,
description=action_description,
hospital=feedback.hospital,
department=feedback.department,
category=action_category,
priority=action_priority,
severity="low",
status="open",
)
AuditService.log_event(
event_type="px_action_created_from_suggestion",
description=f"PX Action created from suggestion: {action.title}",
user=request.user,
content_object=action,
metadata={"suggestion_id": str(feedback.id)},
)
messages.success(request, f"PX Action created successfully from suggestion.")
return redirect("feedback:feedback_detail", pk=feedback.id)
@login_required
@require_http_methods(["POST"])
def feedback_send_to_department(request, pk):
"""Send suggestion to its department for awareness (no response required)."""
from apps.notifications.services import NotificationService
from django.utils import timezone
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
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 this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
if not feedback.department:
messages.error(request, "No department assigned to this suggestion.")
return redirect("feedback:feedback_detail", pk=pk)
dept = feedback.department
note = request.POST.get("note", "").strip()
# Notify department champion and/or manager
notified = []
for role_attr in ("champion", "manager", "deputy_manager"):
staff = getattr(dept, role_attr, None)
if staff and staff.email:
try:
NotificationService.send_email(
email=staff.email,
subject=f"Suggestion Notification - {feedback.title or 'Untitled'}",
message=(
f"Dear {staff.get_full_name()},\n\n"
f"A suggestion has been logged for your department ({dept.name}):\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Category: {feedback.get_category_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"This is for your awareness. No response is required.\n\n"
f"{'Additional note: ' + note if note else ''}"
),
related_object=feedback,
user=staff.user if hasattr(staff, "user") and staff.user else None,
)
notified.append(staff.get_full_name())
except Exception:
pass
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=f"Suggestion sent to {dept.name}" + (f" — notified: {', '.join(notified)}" if notified else ""),
created_by=request.user,
is_internal=False,
)
messages.success(request, f"Suggestion sent to {dept.name}.")
return redirect("feedback:feedback_detail", pk=pk)
@login_required
@require_http_methods(["POST"])
def feedback_send_to(request, pk):
"""
Unified AJAX endpoint to send feedback/suggestion to a department.
Awareness-only: no response required from the department.
"""
import logging
from apps.notifications.services import NotificationService
logger = logging.getLogger(__name__)
feedback = get_object_or_404(Feedback, pk=pk, is_deleted=False)
user = request.user
if not (
user.is_px_admin()
or user.is_hospital_admin()
or user.is_px_management()
or user.is_px_employee()
):
return JsonResponse(
{"success": False, "error": "You don't have permission to send this feedback."},
status=403,
)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
try:
if recipient_type == "person":
person_id = request.POST.get("person_id")
if not person_id:
return JsonResponse(
{"success": False, "error": "Please select a person."},
status=400,
)
try:
person = User.objects.get(id=person_id)
except User.DoesNotExist:
return JsonResponse(
{"success": False, "error": "User not found."},
status=400,
)
feedback.assigned_to = person
feedback.assigned_at = timezone.now()
feedback.save(update_fields=["assigned_to", "assigned_at"])
if person.email:
try:
NotificationService.send_email(
email=person.email,
subject=f"Feedback Assigned - {feedback.title or 'Untitled'}",
message=(
f"Dear {person.get_full_name()},\n\n"
f"You have been assigned to the following feedback:\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Type: {feedback.get_feedback_type_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"{'Additional note: ' + note if note else ''}"
),
related_object=feedback,
user=person,
)
except Exception as e:
logger.warning(f"Failed to send feedback assignment email: {e}")
response_msg = f"Feedback sent to {person.get_full_name()}."
else: # department
department_id = request.POST.get("department_id")
if not department_id:
return JsonResponse(
{"success": False, "error": "Please select a department."},
status=400,
)
try:
department = Department.objects.select_related("champion", "manager").get(
pk=department_id, status="active"
)
except Department.DoesNotExist:
return JsonResponse(
{"success": False, "error": "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": f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.",
},
status=400,
)
# Link department to feedback (awareness-only, no response required)
feedback.department = department
feedback.save(update_fields=["department", "updated_at"])
# Notify champion + manager (email + SMS)
notified = []
link = f"https://{request.get_host()}/suggestions/{feedback.pk}/"
for target in targets:
display_name = (
target["staff"].get_full_name() if target.get("staff")
else (target["user"].get_full_name() if target.get("user") else target["label"])
)
if target.get("email"):
try:
NotificationService.send_email(
email=target["email"],
subject=f"Feedback Notification - {feedback.title or 'Untitled'}",
message=(
f"The following feedback has been logged for your department ({department.name}):\n\n"
f"Title: {feedback.title or 'Untitled'}\n"
f"Type: {feedback.get_feedback_type_display()}\n"
f"Message: {feedback.message[:500]}\n\n"
f"This is for your awareness. No response is required.\n\n"
f"{'Additional note: ' + note if note else ''}\n\n{link}"
),
related_object=feedback,
)
except Exception as e:
logger.warning(f"Failed to send feedback department notification: {e}")
if target.get("phone"):
try:
NotificationService.send_sms(
target["phone"],
f"PX360: Feedback '{feedback.title or 'Untitled'}' logged for {department.name}. {link}",
related_object=feedback,
)
except Exception:
pass
notified.append(f"{display_name} ({target['label']})")
response_msg = f"Feedback sent to {department.get_localized_name()}{', '.join(notified)}."
FeedbackResponse.objects.create(
feedback=feedback,
response_type="note",
message=response_msg,
created_by=user,
is_internal=False,
)
return JsonResponse({"success": True, "message": response_msg})
except Exception as e:
logger.error(f"Error in feedback_send_to: {str(e)}")
return JsonResponse(
{"success": False, "error": "An error occurred while sending the feedback."},
status=500,
)