Two issues from the whole-branch review: 1. (Important) Observation detail's can_respond_to_department context var did not include is_department_manager, so even though Task 2 let dept managers pass the view's permission check, the 'Submit Response' button stayed hidden from them. Now the context var matches the view predicate. Regression test added. 2. (Minor) appreciation_list had no guard against now-invalid status query params (?status=acknowledged from an old bookmark would show an unexplained empty list). Unknown values are now silently reset to 'all statuses'.
2065 lines
83 KiB
Python
2065 lines
83 KiB
Python
"""
|
|
Observations views - Public and internal views for observation management.
|
|
|
|
Public views (no login required):
|
|
- observation_create_public: Submit new observation
|
|
- observation_submitted: Success page with tracking code
|
|
- observation_track: Track observation by code
|
|
|
|
Internal views (login required):
|
|
- observation_list: List all observations with filters
|
|
- observation_detail: View observation details
|
|
- observation_triage: Triage observation
|
|
- observation_change_status: Change observation status
|
|
- observation_add_note: Add internal note
|
|
- observation_convert_to_action: Convert to PX Action
|
|
- category_list: Manage categories
|
|
- category_create/edit/delete: Category CRUD
|
|
"""
|
|
|
|
import logging
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required, permission_required
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Q
|
|
from django.http import HttpResponseForbidden, 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.organizations.models import Department
|
|
|
|
from .forms import (
|
|
ConvertToActionForm,
|
|
ObservationCategoryForm,
|
|
ObservationDepartmentResponseForm,
|
|
ObservationInternalForm,
|
|
ObservationNoteForm,
|
|
ObservationPublicForm,
|
|
ObservationSendToDepartmentForm,
|
|
ObservationStatusForm,
|
|
ObservationTrackForm,
|
|
ObservationTriageForm,
|
|
)
|
|
from .models import (
|
|
Observation,
|
|
ObservationAttachment,
|
|
ObservationCategory,
|
|
ObservationNote,
|
|
ObservationStatus,
|
|
ObservationStatusLog,
|
|
VALID_OBSERVATION_TRANSITIONS,
|
|
)
|
|
from .services import ObservationService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _format_duration(start, end):
|
|
"""Format duration between two datetimes as a human-readable string."""
|
|
if not start or not end:
|
|
return None
|
|
duration = end - start
|
|
total_seconds = int(duration.total_seconds())
|
|
if total_seconds < 60:
|
|
return "1m"
|
|
days = total_seconds // 86400
|
|
hours = (total_seconds % 86400) // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
parts = []
|
|
if days > 0:
|
|
parts.append(f"{days}d")
|
|
if hours > 0:
|
|
parts.append(f"{hours}h")
|
|
if minutes > 0 and days == 0:
|
|
parts.append(f"{minutes}m")
|
|
return " ".join(parts) if parts else "1m"
|
|
|
|
|
|
def _build_observation_stage_timeline(observation):
|
|
"""Build stage timeline with timestamps and durations for an observation."""
|
|
from .models import ObservationStatusLog
|
|
|
|
stages = []
|
|
|
|
status_logs = list(
|
|
ObservationStatusLog.objects.filter(
|
|
observation=observation,
|
|
).select_related("changed_by").order_by("created_at")
|
|
)
|
|
|
|
def _find_log(to_status=None):
|
|
for log in status_logs:
|
|
if to_status and log.to_status != to_status:
|
|
continue
|
|
return log
|
|
return None
|
|
|
|
def _user_name(user):
|
|
return user.get_full_name() if user else None
|
|
|
|
if observation.created_at:
|
|
performed_by = _user_name(observation.created_by) if hasattr(observation, 'created_by') and observation.created_by else str(_("Patient"))
|
|
stages.append({"label": _("Created"), "timestamp": observation.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by})
|
|
|
|
if observation.triaged_at:
|
|
performed_by = _user_name(observation.triaged_by) if hasattr(observation, 'triaged_by') else None
|
|
stages.append({"label": _("Triaged"), "timestamp": observation.triaged_at, "color": "bg-indigo-500", "icon": "filter", "performed_by": performed_by})
|
|
|
|
if observation.activated_at:
|
|
activate_log = _find_log(to_status="in_progress")
|
|
performed_by = _user_name(activate_log.changed_by) if activate_log else None
|
|
stages.append({"label": _("Activated"), "timestamp": observation.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by})
|
|
|
|
if observation.forwarded_to_dept_at:
|
|
stages.append({"label": _("Forwarded to Department"), "timestamp": observation.forwarded_to_dept_at, "color": "bg-purple-500", "icon": "send"})
|
|
|
|
if observation.escalated_at:
|
|
stages.append({"label": _("Escalated"), "timestamp": observation.escalated_at, "color": "bg-red-500", "icon": "alert-triangle"})
|
|
|
|
if observation.dept_response_escalated_at:
|
|
stages.append({"label": _("Dept Response Escalated"), "timestamp": observation.dept_response_escalated_at, "color": "bg-rose-600", "icon": "shield-alert"})
|
|
|
|
if observation.department_responded_at:
|
|
performed_by = _user_name(observation.department_responded_by) if hasattr(observation, 'department_responded_by') else None
|
|
stages.append({"label": _("Department Responded"), "timestamp": observation.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by})
|
|
|
|
if observation.resolved_at:
|
|
performed_by = _user_name(observation.resolved_by) if hasattr(observation, 'resolved_by') else None
|
|
if not performed_by:
|
|
resolved_log = _find_log(to_status="resolved")
|
|
performed_by = _user_name(resolved_log.changed_by) if resolved_log else None
|
|
stages.append({"label": _("Resolved"), "timestamp": observation.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by})
|
|
|
|
if observation.closed_at:
|
|
performed_by = _user_name(observation.closed_by) if hasattr(observation, 'closed_by') else None
|
|
if not performed_by:
|
|
closed_log = _find_log(to_status="closed")
|
|
performed_by = _user_name(closed_log.changed_by) if closed_log else None
|
|
stages.append({"label": _("Closed"), "timestamp": observation.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by})
|
|
|
|
stages.sort(key=lambda s: s["timestamp"])
|
|
|
|
for i, stage in enumerate(stages):
|
|
stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None
|
|
|
|
total_time = None
|
|
if len(stages) >= 2:
|
|
total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"])
|
|
|
|
return {"stages": stages, "total_time": total_time}
|
|
|
|
|
|
# =============================================================================
|
|
# PUBLIC VIEWS (No Login Required)
|
|
# =============================================================================
|
|
|
|
|
|
def observation_create_public(request):
|
|
"""
|
|
Public view for submitting observations.
|
|
|
|
No login required - anonymous submissions allowed.
|
|
"""
|
|
if request.method == "POST":
|
|
form = ObservationPublicForm(request.POST, request.FILES)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
attachments = request.FILES.getlist("attachments")
|
|
|
|
hospital_id = request.POST.get("hospital")
|
|
hospital = None
|
|
if hospital_id:
|
|
try:
|
|
from apps.organizations.models import Hospital
|
|
hospital = Hospital.objects.get(id=hospital_id)
|
|
except Exception:
|
|
pass
|
|
|
|
observation = ObservationService.create_observation(
|
|
description=form.cleaned_data["description"],
|
|
severity=form.cleaned_data.get("severity", "medium"),
|
|
category=None,
|
|
title=form.cleaned_data.get("title", ""),
|
|
location_text=form.cleaned_data.get("location_text", ""),
|
|
location_type=form.cleaned_data.get("location_type", ""),
|
|
incident_datetime=form.cleaned_data.get("incident_datetime"),
|
|
reporter_staff_id=form.cleaned_data.get("reporter_staff_id", ""),
|
|
reporter_name=form.cleaned_data.get("reporter_name", ""),
|
|
reporter_phone=form.cleaned_data.get("reporter_phone", ""),
|
|
reporter_email=form.cleaned_data.get("reporter_email", ""),
|
|
national_id=form.cleaned_data.get("national_id", ""),
|
|
client_ip=client_ip,
|
|
user_agent=user_agent,
|
|
attachments=attachments,
|
|
hospital=hospital,
|
|
area=form.cleaned_data.get("area"),
|
|
section=form.cleaned_data.get("section"),
|
|
source_legacy="public_form",
|
|
)
|
|
|
|
return redirect("observations:observation_submitted", tracking_code=observation.tracking_code)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating observation: {e}")
|
|
messages.error(request, "An error occurred while submitting your observation. Please try again.")
|
|
else:
|
|
form = ObservationPublicForm()
|
|
|
|
from apps.organizations.models import Hospital
|
|
|
|
context = {
|
|
"form": form,
|
|
"hospitals": Hospital.objects.filter(status="active").order_by("name"),
|
|
}
|
|
|
|
return render(request, "observations/public_new.html", context)
|
|
|
|
|
|
def observation_submitted(request, tracking_code):
|
|
"""
|
|
Success page after observation submission.
|
|
|
|
Shows tracking code for future reference.
|
|
"""
|
|
observation = get_object_or_404(Observation, tracking_code=tracking_code)
|
|
|
|
context = {
|
|
"observation": observation,
|
|
"tracking_code": tracking_code,
|
|
}
|
|
|
|
return render(request, "observations/public_success.html", context)
|
|
|
|
|
|
def observation_track(request):
|
|
"""
|
|
Public view to track observation status by tracking code.
|
|
|
|
Shows minimal status information only (no internal notes).
|
|
"""
|
|
observation = None
|
|
error_message = None
|
|
tracking_code = request.GET.get("tracking_code", "").strip()
|
|
|
|
if request.method == "POST":
|
|
tracking_code = request.POST.get("tracking_code", "").strip()
|
|
if not tracking_code:
|
|
error_message = _("Please enter a tracking code.")
|
|
else:
|
|
try:
|
|
observation = (
|
|
Observation.objects.select_related("category", "hospital")
|
|
.prefetch_related("status_logs", "notes")
|
|
.get(tracking_code__iexact=tracking_code)
|
|
)
|
|
except Observation.DoesNotExist:
|
|
error_message = _("No observation found with this tracking code. Please check and try again.")
|
|
elif tracking_code:
|
|
try:
|
|
observation = (
|
|
Observation.objects.select_related("category", "hospital")
|
|
.prefetch_related("status_logs", "notes")
|
|
.get(tracking_code__iexact=tracking_code)
|
|
)
|
|
except Observation.DoesNotExist:
|
|
error_message = _("No observation found with this tracking code. Please check and try again.")
|
|
|
|
has_response = False
|
|
response_en = ""
|
|
response_ar = ""
|
|
if observation:
|
|
has_response = bool(observation.department_response_en or observation.department_response_ar)
|
|
response_en = observation.department_response_en or ""
|
|
response_ar = observation.department_response_ar or ""
|
|
|
|
context = {
|
|
"observation": observation,
|
|
"error_message": error_message,
|
|
"tracking_code": tracking_code,
|
|
"has_response": has_response,
|
|
"response_en": response_en,
|
|
"response_ar": response_ar,
|
|
}
|
|
|
|
return render(request, "observations/public_track.html", context)
|
|
|
|
|
|
# =============================================================================
|
|
# INTERNAL VIEWS (Login Required)
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def observation_create(request):
|
|
"""
|
|
Internal view for authenticated staff to create observations.
|
|
"""
|
|
communication_request = None
|
|
if request.method == "POST":
|
|
form = ObservationInternalForm(request.POST, request=request)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
client_ip = get_client_ip(request)
|
|
user_agent = request.META.get("HTTP_USER_AGENT", "")
|
|
user = request.user
|
|
|
|
observation = ObservationService.create_observation(
|
|
description=form.cleaned_data["description"],
|
|
severity=form.cleaned_data.get("severity") or request.POST.get("severity", "medium"),
|
|
category=None,
|
|
title=form.cleaned_data.get("title", "") or "",
|
|
location_text=form.cleaned_data.get("location_text", "") or "",
|
|
incident_datetime=form.cleaned_data.get("incident_datetime"),
|
|
reporter_staff_id=user.employee_id or "",
|
|
reporter_name=user.get_full_name(),
|
|
reporter_phone="",
|
|
reporter_email=user.email,
|
|
client_ip=client_ip,
|
|
user_agent=user_agent,
|
|
hospital=form.cleaned_data.get("hospital") or (user.hospital if hasattr(user, 'hospital') else None),
|
|
assigned_department=form.cleaned_data.get("assigned_department"),
|
|
source=form.cleaned_data.get("px_source"),
|
|
source_legacy="staff_portal",
|
|
)
|
|
|
|
assigned_dept = form.cleaned_data.get("assigned_department")
|
|
section = form.cleaned_data.get("section")
|
|
if assigned_dept:
|
|
observation.assigned_department = assigned_dept
|
|
if section:
|
|
observation.section = section
|
|
|
|
observation.patient_name = form.cleaned_data.get("patient_name", "") or ""
|
|
observation.patient_national_id = form.cleaned_data.get("patient_national_id", "") or ""
|
|
observation.patient_phone = form.cleaned_data.get("patient_phone", "") or ""
|
|
observation.patient_email = form.cleaned_data.get("patient_email", "") or ""
|
|
|
|
observation.assigned_to = user
|
|
observation.status = ObservationStatus.IN_PROGRESS
|
|
observation.save()
|
|
|
|
ObservationStatusLog.objects.create(
|
|
observation=observation,
|
|
from_status=ObservationStatus.OPEN,
|
|
to_status=ObservationStatus.IN_PROGRESS,
|
|
comment="Auto-assigned during creation",
|
|
)
|
|
|
|
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(observation)
|
|
except Exception:
|
|
pass
|
|
|
|
StaffActivityService.log_from_request(
|
|
request,
|
|
activity_type="create",
|
|
description=f"Created observation {observation.tracking_code}",
|
|
content_object=observation,
|
|
module="observations",
|
|
)
|
|
|
|
messages.success(request, f"Observation {observation.tracking_code} created successfully.")
|
|
return redirect("observations:observation_detail", pk=observation.id)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating observation: {e}")
|
|
messages.error(request, "An error occurred while creating the observation. Please try again.")
|
|
else:
|
|
communication_request = None
|
|
comm_req_id = request.GET.get("comm_req")
|
|
initial_data = {}
|
|
if comm_req_id:
|
|
try:
|
|
from apps.px_sources.models import CommunicationRequest
|
|
cr_data = CommunicationRequest.get_initial_data(comm_req_id)
|
|
communication_request = cr_data["communication_request"]
|
|
for key in ("patient_file_number", "description"):
|
|
if key in cr_data["initial"] and cr_data["initial"][key]:
|
|
initial_data[key] = cr_data["initial"][key]
|
|
except Exception:
|
|
pass
|
|
|
|
form = ObservationInternalForm(request=request, initial=initial_data)
|
|
|
|
context = {
|
|
"form": form,
|
|
"communication_request": communication_request,
|
|
}
|
|
|
|
return render(request, "observations/observation_create.html", context)
|
|
|
|
|
|
@login_required
|
|
def observation_list(request):
|
|
"""
|
|
Internal view for listing observations with filters.
|
|
|
|
Features:
|
|
- Server-side pagination
|
|
- Advanced filters (status, severity, category, department, etc.)
|
|
- Search by tracking code, description
|
|
- RBAC filtering
|
|
"""
|
|
# Base queryset with optimizations
|
|
queryset = Observation.objects.select_related(
|
|
"category", "assigned_department", "assigned_to", "triaged_by", "resolved_by", "closed_by"
|
|
)
|
|
|
|
# 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(
|
|
Q(assigned_department__hospital=selected_hospital, hospital=selected_hospital) |
|
|
Q(assigned_department__isnull=True, hospital=selected_hospital)
|
|
)
|
|
elif user.is_hospital_admin() and user.hospital:
|
|
queryset = queryset.filter(
|
|
Q(assigned_department__hospital=user.hospital, hospital=user.hospital) |
|
|
Q(assigned_department__isnull=True, hospital=user.hospital)
|
|
)
|
|
elif user.is_department_manager() and user.department:
|
|
queryset = queryset.filter(assigned_department=user.department)
|
|
elif user.hospital:
|
|
queryset = queryset.filter(Q(assigned_department__hospital=user.hospital) | Q(assigned_department__isnull=True))
|
|
|
|
# Apply filters from request
|
|
status_filter = request.GET.get("status")
|
|
if status_filter:
|
|
queryset = queryset.filter(status=status_filter)
|
|
|
|
severity_filter = request.GET.get("severity")
|
|
if severity_filter:
|
|
queryset = queryset.filter(severity=severity_filter)
|
|
|
|
category_filter = request.GET.get("category")
|
|
if category_filter:
|
|
queryset = queryset.filter(category_id=category_filter)
|
|
|
|
department_filter = request.GET.get("assigned_department")
|
|
if department_filter:
|
|
queryset = queryset.filter(assigned_department_id=department_filter)
|
|
|
|
assigned_to_filter = request.GET.get("assigned_to")
|
|
if assigned_to_filter:
|
|
queryset = queryset.filter(assigned_to_id=assigned_to_filter)
|
|
|
|
is_anonymous_filter = request.GET.get("is_anonymous")
|
|
if is_anonymous_filter == "yes":
|
|
queryset = queryset.filter(reporter_staff_id="", reporter_name="")
|
|
elif is_anonymous_filter == "no":
|
|
queryset = queryset.exclude(reporter_staff_id="", reporter_name="")
|
|
|
|
# Search
|
|
search_query = request.GET.get("search")
|
|
if search_query:
|
|
queryset = queryset.filter(
|
|
Q(tracking_code__icontains=search_query)
|
|
| Q(title__icontains=search_query)
|
|
| Q(description__icontains=search_query)
|
|
| Q(reporter_name__icontains=search_query)
|
|
| Q(reporter_staff_id__icontains=search_query)
|
|
| Q(location_text__icontains=search_query)
|
|
)
|
|
|
|
# Date range filters
|
|
date_from = request.GET.get("date_from")
|
|
if date_from:
|
|
queryset = queryset.filter(created_at__date__gte=date_from)
|
|
|
|
date_to = request.GET.get("date_to")
|
|
if date_to:
|
|
queryset = queryset.filter(created_at__date__lte=date_to)
|
|
|
|
ALLOWED_ORDER_BY = {"-created_at", "created_at", "-updated_at", "updated_at", "severity", "-severity"}
|
|
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)
|
|
|
|
# Get filter options
|
|
departments = Department.objects.filter(status="active")
|
|
if not user.is_px_admin() and user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(user.hospital)
|
|
|
|
categories = ObservationCategory.objects.filter(is_active=True)
|
|
|
|
stats = ObservationService.get_statistics(
|
|
hospital=selected_hospital or (user.hospital if not user.is_px_admin() else None),
|
|
department=user.department if user.is_department_manager() else None,
|
|
)
|
|
|
|
context = {
|
|
"page_obj": page_obj,
|
|
"observations": page_obj.object_list,
|
|
"stats": stats,
|
|
"departments": departments,
|
|
"assignable_users": assignable_users,
|
|
"categories": categories,
|
|
"status_choices": ObservationStatus.choices,
|
|
"filters": request.GET,
|
|
}
|
|
|
|
return render(request, "observations/observation_list.html", context)
|
|
|
|
|
|
@login_required
|
|
def observation_detail(request, pk):
|
|
"""
|
|
Internal view for observation details.
|
|
|
|
Features:
|
|
- Full observation details
|
|
- Timeline (status logs + notes)
|
|
- Attachments
|
|
- Linked PX Action
|
|
- Workflow actions
|
|
"""
|
|
observation = get_object_or_404(
|
|
Observation.objects.select_related(
|
|
"category", "assigned_department", "assigned_to", "triaged_by", "resolved_by", "closed_by",
|
|
"legacy_location", "legacy_main_section", "legacy_subsection", "hospital",
|
|
"taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification",
|
|
).prefetch_related("attachments", "observation_notes__created_by", "status_logs__changed_by"),
|
|
pk=pk,
|
|
)
|
|
|
|
# Check access
|
|
user = request.user
|
|
if not user.is_px_admin():
|
|
if user.is_hospital_admin() and observation.assigned_department:
|
|
if observation.assigned_department.hospital != user.hospital:
|
|
messages.error(request, "You don't have permission to view this observation.")
|
|
return redirect("observations:observation_list")
|
|
elif user.is_department_manager() and observation.assigned_department:
|
|
if observation.assigned_department != user.department:
|
|
messages.error(request, "You don't have permission to view this observation.")
|
|
return redirect("observations:observation_list")
|
|
# Fallback: if no assigned_department, check by hospital
|
|
if not observation.assigned_department and observation.hospital_id:
|
|
if not user.is_px_admin() and user.hospital_id and observation.hospital_id != user.hospital_id:
|
|
messages.error(request, "You don't have permission to view this observation.")
|
|
return redirect("observations:observation_list")
|
|
|
|
# Get timeline (combine status logs and notes)
|
|
status_logs = list(observation.status_logs.all())
|
|
notes = list(observation.observation_notes.all())
|
|
|
|
timeline = []
|
|
for log in status_logs:
|
|
timeline.append(
|
|
{
|
|
"type": "status_change",
|
|
"created_at": log.created_at,
|
|
"item": log,
|
|
}
|
|
)
|
|
for note in notes:
|
|
timeline.append(
|
|
{
|
|
"type": "note",
|
|
"created_at": note.created_at,
|
|
"item": note,
|
|
}
|
|
)
|
|
timeline.sort(key=lambda x: x["created_at"], reverse=True)
|
|
stage_timeline = _build_observation_stage_timeline(observation)
|
|
|
|
# Get attachments
|
|
attachments = observation.attachments.all()
|
|
|
|
# Get linked PX Action if exists
|
|
px_action = None
|
|
if observation.action_id:
|
|
from apps.px_action_center.models import PXAction
|
|
|
|
try:
|
|
px_action = PXAction.objects.get(id=observation.action_id)
|
|
except PXAction.DoesNotExist:
|
|
pass
|
|
|
|
# Get assignable users and departments.
|
|
# Only offer departments that can actually receive a send (have a champion or an
|
|
# emailable manager) — matches the send endpoint's get_champion_and_manager() check.
|
|
from apps.organizations.department_contacts import has_contact_target
|
|
from apps.core.utils import get_assignable_users
|
|
assignable_users = get_assignable_users(user.hospital)
|
|
departments = (
|
|
Department.objects.filter(status="active")
|
|
.select_related("champion", "manager", "manager__staff_profile")
|
|
)
|
|
if user.hospital:
|
|
departments = departments.filter(hospital=user.hospital)
|
|
departments = [d for d in departments if has_contact_target(d)]
|
|
|
|
# Forms
|
|
triage_form = ObservationTriageForm(
|
|
initial={
|
|
"assigned_department": observation.assigned_department,
|
|
"assigned_to": observation.assigned_to,
|
|
"status": observation.status,
|
|
},
|
|
hospital=user.hospital,
|
|
)
|
|
status_form = ObservationStatusForm(initial={"status": observation.status})
|
|
note_form = ObservationNoteForm()
|
|
|
|
from apps.rca.models import RootCauseAnalysis as RCA
|
|
from django.contrib.contenttypes.models import ContentType as CT
|
|
|
|
observation_ct = CT.objects.get_for_model(Observation)
|
|
linked_rcas = RCA.objects.filter(
|
|
content_type=observation_ct, object_id=observation.pk, is_deleted=False
|
|
).select_related("assigned_to", "created_by")
|
|
|
|
context = {
|
|
"observation": observation,
|
|
"timeline": timeline,
|
|
"stage_timeline": stage_timeline,
|
|
"attachments": attachments,
|
|
"px_action": px_action,
|
|
"departments": departments,
|
|
"assignable_users": assignable_users,
|
|
"triage_form": triage_form,
|
|
"status_form": status_form,
|
|
"note_form": note_form,
|
|
"status_choices": ObservationStatus.choices,
|
|
"can_triage": user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee(),
|
|
"can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
|
|
"can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_department_manager() or user.is_px_management(),
|
|
"can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or (user.is_champion() and observation.assigned_department == user.department) or (user.is_department_manager() and observation.assigned_department == user.department),
|
|
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
|
|
"can_delete": 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() or user.is_px_employee(),
|
|
"linked_rcas": linked_rcas,
|
|
}
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
|
obs_ct = ContentType.objects.get_for_model(observation)
|
|
context["content_type_id"] = obs_ct.pk
|
|
context["object_id"] = observation.pk
|
|
generic_notes = observation.notes.select_related("created_by").all()
|
|
context["generic_notes"] = generic_notes
|
|
context["generic_notes_count"] = generic_notes.count()
|
|
|
|
escalation_targets = []
|
|
escalation_email_subject = ""
|
|
escalation_email_body = ""
|
|
|
|
if observation.assigned_department:
|
|
from apps.organizations.models import Staff as OrgStaff
|
|
|
|
dept = observation.assigned_department
|
|
dept_holders = [
|
|
h for h in dept.get_role_holders()
|
|
if h["staff"].user and h["staff"].user.is_active
|
|
]
|
|
escalation_targets.extend(dept_holders)
|
|
|
|
hospital_admins = OrgStaff.objects.filter(
|
|
user__is_active=True,
|
|
user__groups__name__in=["Hospital Admin", "Department Manager"],
|
|
)
|
|
if observation.hospital:
|
|
hospital_admins = hospital_admins.filter(department__hospital=observation.hospital)
|
|
for admin in hospital_admins.select_related("user"):
|
|
escalation_targets.append({
|
|
"staff": admin,
|
|
"staff_id": str(admin.id),
|
|
"name": admin.get_full_name(),
|
|
"email": admin.email or (admin.user.email if admin.user else None),
|
|
"role_field": "hospital_admin",
|
|
"role_label": "Hospital Admin" if admin.user.groups.filter(name="Hospital Admin").exists() else "Department Manager",
|
|
})
|
|
|
|
dept_name = dept.get_localized_name()
|
|
escalation_email_subject = f"Observation Escalation - {observation.tracking_code} - {dept_name}"
|
|
escalation_email_body = (
|
|
f"Dear Team,\n\n"
|
|
f"This is to escalate observation {observation.tracking_code} regarding the {dept_name} department.\n\n"
|
|
f"Severity: {observation.get_severity_display()}\n"
|
|
f"Status: {observation.get_status_display()}\n"
|
|
f"Description: {observation.description[:500]}\n\n"
|
|
f"Please review and take appropriate action.\n\n"
|
|
f"Thank you."
|
|
)
|
|
|
|
context["escalation_targets"] = escalation_targets
|
|
context["escalation_email_subject"] = escalation_email_subject
|
|
context["escalation_email_body"] = escalation_email_body
|
|
|
|
# Workflow stepper
|
|
_obs_steps = [
|
|
{"label": "Created", "icon": "plus-circle", "done": True},
|
|
{"label": "Assigned", "icon": "user-plus", "done": observation.assigned_at is not None},
|
|
{"label": "Sent to Dept", "icon": "send", "done": observation.forwarded_to_dept_at is not None},
|
|
{"label": "Dept Response", "icon": "message-square", "done": bool(observation.department_response_en or observation.department_response_ar)},
|
|
{"label": "Resolved", "icon": "check-circle-2", "done": observation.status in ("resolved", "closed")},
|
|
]
|
|
_obs_next = next((i for i, s in enumerate(_obs_steps) if not s["done"]), len(_obs_steps))
|
|
if _obs_next < len(_obs_steps):
|
|
_obs_steps[_obs_next]["current"] = True
|
|
context["workflow_steps"] = _obs_steps
|
|
|
|
return render(request, "observations/observation_detail.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_triage(request, pk):
|
|
"""
|
|
Triage an observation - assign department/owner and update status.
|
|
"""
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_px_employee()):
|
|
messages.error(request, "You don't have permission to triage observations.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
form = ObservationTriageForm(request.POST, hospital=user.hospital)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
ObservationService.triage_observation(
|
|
observation=observation,
|
|
triaged_by=user,
|
|
assigned_department=form.cleaned_data.get("assigned_department"),
|
|
assigned_to=form.cleaned_data.get("assigned_to"),
|
|
new_status=form.cleaned_data.get("status"),
|
|
note=form.cleaned_data.get("note", ""),
|
|
)
|
|
messages.success(request, "Observation triaged successfully.")
|
|
except Exception as e:
|
|
logger.error(f"Error triaging observation: {e}")
|
|
messages.error(request, f"Error triaging observation: {str(e)}")
|
|
else:
|
|
messages.error(request, "Invalid form data.")
|
|
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_change_status(request, pk):
|
|
"""
|
|
Change observation status.
|
|
"""
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
# Check permission
|
|
user = request.user
|
|
if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()):
|
|
messages.error(request, "You don't have permission to change observation status.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
form = ObservationStatusForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
ObservationService.change_status(
|
|
observation=observation,
|
|
new_status=form.cleaned_data["status"],
|
|
changed_by=user,
|
|
comment=form.cleaned_data.get("comment", ""),
|
|
)
|
|
messages.success(request, f"Status changed to {form.cleaned_data['status']}.")
|
|
except Exception as e:
|
|
logger.error(f"Error changing status: {e}")
|
|
messages.error(request, f"Error changing status: {str(e)}")
|
|
else:
|
|
messages.error(request, "Invalid form data.")
|
|
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_assign(request, pk):
|
|
observation = get_object_or_404(Observation, 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 assign observations.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
user_id = request.POST.get("user_id")
|
|
if not user_id:
|
|
messages.error(request, "Please select a user to assign.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
try:
|
|
assignee = User.objects.get(id=user_id)
|
|
old_assignee = observation.assigned_to
|
|
old_status = observation.status
|
|
|
|
observation.assigned_to = assignee
|
|
observation.assigned_at = timezone.now()
|
|
|
|
reopened = False
|
|
new_status = old_status
|
|
if old_status in ("resolved", "closed"):
|
|
new_status = ObservationStatus.IN_PROGRESS
|
|
observation.resolved_at = None
|
|
observation.resolved_by = None
|
|
observation.closed_at = None
|
|
observation.closed_by = None
|
|
reopened = True
|
|
elif old_status == ObservationStatus.OPEN:
|
|
new_status = ObservationStatus.IN_PROGRESS
|
|
|
|
observation.save()
|
|
|
|
ObservationService.change_status(
|
|
observation=observation,
|
|
new_status=new_status,
|
|
changed_by=request.user,
|
|
comment=f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}"
|
|
+ (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""),
|
|
)
|
|
|
|
messages.success(request, f"Observation {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.")
|
|
except User.DoesNotExist:
|
|
messages.error(request, "User not found.")
|
|
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_activate(request, pk):
|
|
"""Activate observation and assign to current user."""
|
|
observation = get_object_or_404(Observation, 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 activate observations."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if observation.assigned_to == user:
|
|
messages.info(request, _("This observation is already assigned to you."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
old_assignee = observation.assigned_to
|
|
old_status = observation.status
|
|
|
|
observation.assigned_to = user
|
|
observation.assigned_at = timezone.now()
|
|
observation.save(update_fields=["assigned_to", "assigned_at"])
|
|
|
|
if old_status == ObservationStatus.OPEN:
|
|
ObservationService.change_status(
|
|
observation=observation,
|
|
new_status=ObservationStatus.IN_PROGRESS,
|
|
changed_by=user,
|
|
comment=f"Observation activated and assigned to {user.get_full_name()}"
|
|
+ (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""),
|
|
)
|
|
else:
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Observation reassigned to {user.get_full_name()}"
|
|
+ (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""),
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
StaffActivityService.log_from_request(
|
|
request,
|
|
activity_type="update",
|
|
description=f"Activated observation {observation.tracking_code or observation.id}",
|
|
content_object=observation,
|
|
module="observations",
|
|
)
|
|
|
|
messages.success(request, _("Observation activated and assigned to you successfully."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_reopen(request, pk):
|
|
observation = get_object_or_404(Observation, 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 reopen observations.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if observation.status not in ("resolved", "closed"):
|
|
messages.error(request, "Only resolved or closed observations can be reopened.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
old_status = observation.status
|
|
note_text = request.POST.get("note", "Observation reopened")
|
|
|
|
ObservationService.change_status(
|
|
observation=observation,
|
|
new_status=ObservationStatus.IN_PROGRESS,
|
|
changed_by=user,
|
|
comment=note_text,
|
|
)
|
|
|
|
observation.resolved_at = None
|
|
observation.resolved_by = None
|
|
observation.closed_at = None
|
|
observation.closed_by = None
|
|
observation.save(update_fields=["resolved_at", "resolved_by", "closed_at", "closed_by"])
|
|
|
|
messages.success(request, "Observation reopened successfully.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_add_note(request, pk):
|
|
"""
|
|
Add internal note to observation.
|
|
"""
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
form = ObservationNoteForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
ObservationService.add_note(
|
|
observation=observation,
|
|
note=form.cleaned_data["note"],
|
|
created_by=request.user,
|
|
is_internal=form.cleaned_data.get("is_internal", True),
|
|
)
|
|
messages.success(request, "Note added successfully.")
|
|
except Exception as e:
|
|
logger.error(f"Error adding note: {e}")
|
|
messages.error(request, f"Error adding note: {str(e)}")
|
|
else:
|
|
messages.error(request, "Please enter a note.")
|
|
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_respond(request, pk):
|
|
"""Respond to observation with patient-facing response text."""
|
|
observation = get_object_or_404(Observation, 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()
|
|
or observation.assigned_to == user
|
|
):
|
|
messages.error(request, _("You don't have permission to respond to observations."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
response = request.POST.get("response", "").strip()
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
observation.response = response
|
|
observation.response_en = ""
|
|
observation.response_ar = ""
|
|
observation.responded_at = timezone.now()
|
|
observation.responded_by = request.user
|
|
observation.response_sent_at = timezone.now()
|
|
if observation.status not in ("resolved", "closed"):
|
|
observation.status = "resolved"
|
|
observation.resolved_at = timezone.now()
|
|
observation.resolved_by = request.user
|
|
observation.save()
|
|
|
|
from apps.core.services import AuditService
|
|
AuditService.log_event(
|
|
event_type="observation_responded",
|
|
description=f"Response sent for observation {observation.tracking_code or observation.id}",
|
|
user=request.user,
|
|
content_object=observation,
|
|
)
|
|
|
|
messages.success(request, _("Response sent successfully."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_generate_ai_response(request, pk):
|
|
"""Generate AI-powered response for an observation in both English and Arabic."""
|
|
from django.http import JsonResponse
|
|
from apps.core.ai_service import AIService
|
|
from apps.core.services import AuditService
|
|
import json, logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
observation = get_object_or_404(Observation, 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()
|
|
or observation.assigned_to == user
|
|
):
|
|
return JsonResponse({"error": "You don't have permission."}, status=403)
|
|
|
|
try:
|
|
ai_desc_en = ""
|
|
ai_desc_ar = ""
|
|
if observation.metadata and "ai_analysis" in observation.metadata:
|
|
ai_desc_en = observation.metadata["ai_analysis"].get("short_description_en", "")
|
|
ai_desc_ar = observation.metadata["ai_analysis"].get("short_description_ar", "")
|
|
|
|
dept_section = ""
|
|
if observation.department_response_en or observation.department_response_ar:
|
|
dept_en = observation.department_response_en or ""
|
|
dept_ar = observation.department_response_ar or ""
|
|
dept_section = f"""
|
|
DEPARTMENT RESPONSE (use this as the primary basis):
|
|
- English: {dept_en}
|
|
- Arabic: {dept_ar}
|
|
|
|
Transform the department response into a clear, patient-friendly response."""
|
|
|
|
prompt = f"""As a healthcare observation response specialist, generate a professional response to this observation in BOTH English and Arabic.
|
|
|
|
OBSERVATION DETAILS:
|
|
- Description: {observation.description}
|
|
- Category: {observation.category.name if observation.category else 'General'}
|
|
- Hospital: {observation.hospital.name if observation.hospital else 'Unknown'}
|
|
|
|
AI SUMMARY (for context):
|
|
- English: {ai_desc_en}
|
|
- Arabic: {ai_desc_ar}
|
|
{dept_section}
|
|
|
|
Generate a professional response that:
|
|
1. Acknowledges the observation and thanks the reporter
|
|
2. Addresses what was observed
|
|
3. Explains any actions taken or planned
|
|
4. Uses a professional, empathetic tone
|
|
|
|
IMPORTANT: Provide the response in BOTH languages as JSON:
|
|
{{
|
|
"response_en": "The response text in English (2-4 paragraphs)",
|
|
"response_ar": "نص الرد بالعربية (2-4 فقرات)"
|
|
}}"""
|
|
|
|
system_prompt = """You are an expert healthcare observation response specialist fluent in both English and Arabic.
|
|
Generate comprehensive, professional responses in both languages. Use Modern Standard Arabic (Fusha)."""
|
|
|
|
ai_response = AIService.chat_completion(
|
|
prompt=prompt,
|
|
system_prompt=system_prompt,
|
|
temperature=0.4,
|
|
max_tokens=1500,
|
|
response_format="json_object",
|
|
)
|
|
|
|
response_data = json.loads(ai_response)
|
|
response_en = response_data.get("response_en", "").strip()
|
|
response_ar = response_data.get("response_ar", "").strip()
|
|
|
|
AuditService.log_event(
|
|
event_type="ai_observation_response_generated",
|
|
description=f"AI response generated for observation {observation.tracking_code}",
|
|
user=request.user,
|
|
content_object=observation,
|
|
)
|
|
|
|
return JsonResponse({"success": True, "response_en": response_en, "response_ar": response_ar})
|
|
|
|
except Exception as e:
|
|
logger.error(f"AI observation response generation failed: {e}")
|
|
return JsonResponse({"success": False, "error": f"Failed to generate response: {str(e)}"}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def observation_convert_to_action(request, pk):
|
|
"""
|
|
Convert observation to PX Action.
|
|
"""
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
# 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 convert observations to actions.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
# Check if already converted
|
|
if observation.action_id:
|
|
messages.warning(request, "This observation has already been converted to an action.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if request.method == "POST":
|
|
form = ConvertToActionForm(request.POST)
|
|
|
|
if form.is_valid():
|
|
try:
|
|
action = ObservationService.convert_to_action(
|
|
observation=observation,
|
|
created_by=user,
|
|
title=form.cleaned_data["title"],
|
|
description=form.cleaned_data["description"],
|
|
category=form.cleaned_data["category"],
|
|
priority=form.cleaned_data["priority"],
|
|
assigned_department=form.cleaned_data.get("assigned_department"),
|
|
assigned_to=form.cleaned_data.get("assigned_to"),
|
|
)
|
|
messages.success(request, f"Observation converted to action successfully.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
except Exception as e:
|
|
logger.error(f"Error converting to action: {e}")
|
|
messages.error(request, f"Error converting to action: {str(e)}")
|
|
else:
|
|
# Pre-populate form
|
|
initial = {
|
|
"title": f"Observation {observation.tracking_code}",
|
|
"description": observation.description,
|
|
"priority": observation.severity,
|
|
"assigned_department": observation.assigned_department,
|
|
"assigned_to": observation.assigned_to,
|
|
}
|
|
if observation.title:
|
|
initial["title"] += f" - {observation.title}"
|
|
elif observation.category:
|
|
initial["title"] += f" - {observation.category.name_en}"
|
|
|
|
form = ConvertToActionForm(initial=initial)
|
|
|
|
context = {
|
|
"observation": observation,
|
|
"form": form,
|
|
}
|
|
|
|
return render(request, "observations/convert_to_action.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_send_to_department(request, pk):
|
|
"""
|
|
Send an observation to a department for response.
|
|
"""
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
user = request.user
|
|
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()
|
|
):
|
|
messages.error(request, _("You don't have permission to send observations to departments."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
# Must be activated (in_progress) before it can be sent to a department
|
|
if observation.status == ObservationStatus.OPEN:
|
|
messages.error(request, _("Activate this observation before sending it to a department."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
department_id = request.POST.get("department_id")
|
|
if not department_id:
|
|
messages.error(request, _("Please select a department."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
from apps.organizations.models import Department
|
|
try:
|
|
department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active")
|
|
except Department.DoesNotExist:
|
|
messages.error(request, _("Department not found."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if not department.champion and not department.manager:
|
|
messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
contact_person_id = request.POST.get("contact_person_id")
|
|
if not contact_person_id:
|
|
messages.error(request, _("Please select a contact person."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
contact_info = department.is_valid_contact_person(contact_person_id)
|
|
if not contact_info:
|
|
messages.error(request, _("Selected person is not a role holder in this department."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
contact_person = contact_info["staff"]
|
|
|
|
note_en = request.POST.get("note_en", "").strip()
|
|
note_ar = request.POST.get("note_ar", "").strip()
|
|
recipient_type = request.POST.get("recipient_type", "staff")
|
|
|
|
note_parts = []
|
|
if note_en:
|
|
note_parts.append(note_en)
|
|
if note_ar:
|
|
note_parts.append(note_ar)
|
|
combined_note = "\n\n".join(note_parts)
|
|
|
|
# Use assigned_department as the outgoing department (reusing existing field)
|
|
observation.assigned_department = department
|
|
observation.forwarded_to_dept_at = timezone.now()
|
|
observation.sent_to_department = True
|
|
observation.sent_to_department_at = timezone.now()
|
|
|
|
sla_config = observation.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
observation.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
observation.dept_response_is_overdue = False
|
|
observation.dept_response_reminder_sent_at = None
|
|
observation.dept_response_second_reminder_sent_at = None
|
|
observation.dept_response_escalated_at = None
|
|
|
|
old_status = observation.status
|
|
observation.contact_status = "contacted"
|
|
observation.contact_status_at = timezone.now()
|
|
observation.contact_status_by = user
|
|
observation.save()
|
|
|
|
# Create note
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=combined_note or f"Sent to {department.get_localized_name()} for response",
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
try:
|
|
import secrets
|
|
from apps.notifications.settings_service import NotificationServiceWithSettings
|
|
|
|
# Generate token for response link
|
|
response_token = secrets.token_urlsafe(32)
|
|
observation.response_token = response_token
|
|
observation.response_token_sent_at = timezone.now()
|
|
observation.save(update_fields=["response_token", "response_token_sent_at"])
|
|
|
|
# Build response link
|
|
from django.contrib.sites.shortcuts import get_current_site
|
|
current_site = get_current_site(request)
|
|
domain = current_site.domain if current_site else request.get_host()
|
|
response_link = f"https://{domain}/observations/{observation.pk}/respond/{response_token}/"
|
|
|
|
NotificationServiceWithSettings.send_observation_department_assigned(
|
|
department, observation, context_note_en=note_en, context_note_ar=note_ar,
|
|
recipient_type=recipient_type,
|
|
)
|
|
|
|
contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None)
|
|
if contact_email:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
NotificationService.send_email(
|
|
email=contact_email,
|
|
subject=f"Observation {observation.tracking_code} - Response Required",
|
|
message=f"An observation has been sent to your department ({department.get_localized_name()}) for response.\n\nSubmit your response here: {response_link}\n\nDeadline: {observation.dept_response_sla_due_at}",
|
|
html_message=f"""
|
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Observation {observation.tracking_code} - Response Required</h2>
|
|
<p style="margin: 0 0 12px 0;">An observation has been sent to your department (<strong>{department.get_localized_name()}</strong>) for response.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Tracking Code:</td><td style="padding: 4px 0;">{observation.tracking_code}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Deadline:</td><td style="padding: 4px 0;">{observation.dept_response_sla_due_at}</td></tr>
|
|
</table>
|
|
<div style="text-align: center; margin: 20px 0;">
|
|
<a href="{response_link}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">Submit Response</a>
|
|
</div>
|
|
<p style="margin: 0; color: #6b7280; font-size: 13px;">This link can only be used once. After submission, it will expire.</p>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=observation,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send department notification: {e}")
|
|
|
|
if recipient_type == "department_email":
|
|
messages.success(
|
|
request,
|
|
_("Observation sent to %(dept)s department email.")
|
|
% {"dept": department.get_localized_name()},
|
|
)
|
|
else:
|
|
messages.success(
|
|
request,
|
|
_("Observation sent to %(dept)s. Department champions have been notified.")
|
|
% {"dept": department.get_localized_name()},
|
|
)
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_escalate(request, pk):
|
|
from apps.organizations.models import Staff
|
|
from django.contrib import messages as django_messages
|
|
|
|
observation = get_object_or_404(Observation, 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 escalate observations."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if observation.status in ("closed", "cancelled", "rejected"):
|
|
messages.error(request, _("Cannot escalate a closed, cancelled, or rejected observation."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
escalate_to_id = request.POST.get("escalate_to", "")
|
|
reason = request.POST.get("reason", "")
|
|
|
|
escalate_to_staff = None
|
|
escalate_to_user = None
|
|
|
|
if escalate_to_id:
|
|
try:
|
|
escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active")
|
|
if escalate_to_staff.user and escalate_to_staff.user.is_active:
|
|
escalate_to_user = escalate_to_staff.user
|
|
except Staff.DoesNotExist:
|
|
pass
|
|
|
|
if not escalate_to_user:
|
|
messages.error(request, _("Please select a valid person to escalate to."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
observation.escalated_at = timezone.now()
|
|
observation.save(update_fields=["escalated_at"])
|
|
|
|
ObservationStatusLog.objects.create(
|
|
observation=observation,
|
|
from_status=observation.status,
|
|
to_status=observation.status,
|
|
changed_by=user,
|
|
comment=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}",
|
|
)
|
|
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}",
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
email_subject = request.POST.get("email_subject", f"Observation Escalated - {observation.tracking_code}")
|
|
email_body = request.POST.get("email_body", "")
|
|
|
|
if escalate_to_user.email:
|
|
try:
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
NotificationService.send_email(
|
|
email=escalate_to_user.email,
|
|
subject=email_subject,
|
|
message=email_body,
|
|
html_message=f"""
|
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;">
|
|
{get_email_header_html()}
|
|
<div style="padding: 20px;">
|
|
<h2 style="color: #005696; font-size: 18px; margin: 0 0 12px 0;">Observation Escalated - {observation.tracking_code}</h2>
|
|
<p style="margin: 0 0 12px 0;">An observation has been escalated to you.</p>
|
|
<table style="width: 100%; border-collapse: collapse; margin: 0 0 12px 0;">
|
|
<tr><td style="padding: 4px 0; color: #6b7280; width: 120px;">Tracking Code:</td><td style="padding: 4px 0;">{observation.tracking_code}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Escalated by:</td><td style="padding: 4px 0;">{user.get_full_name()}</td></tr>
|
|
<tr><td style="padding: 4px 0; color: #6b7280;">Reason:</td><td style="padding: 4px 0;">{reason or 'N/A'}</td></tr>
|
|
</table>
|
|
<div style="background: #f9fafb; padding: 12px; border-radius: 6px; margin: 0 0 12px 0;">
|
|
<p style="margin: 0;">{email_body}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=observation,
|
|
metadata={
|
|
"notification_type": "observation_escalated",
|
|
"escalated_by": str(user.id),
|
|
"reason": reason,
|
|
},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Failed to send observation escalation email: {e}")
|
|
|
|
messages.success(request, _(f"Observation escalated to {escalate_to_staff.get_full_name()}."))
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_send_to(request, pk):
|
|
"""
|
|
Unified AJAX endpoint to send observation to either a person or department.
|
|
"""
|
|
from django.http import JsonResponse
|
|
from apps.notifications.services import NotificationService, get_email_header_html
|
|
from apps.accounts.models import User
|
|
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
user = request.user
|
|
|
|
# Check 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 observation.")),
|
|
}, status=403)
|
|
|
|
# Must be activated (in_progress) before it can be sent/assigned
|
|
if observation.status == ObservationStatus.OPEN:
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("Activate this observation before sending it to a department.")),
|
|
}, status=400)
|
|
|
|
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": 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)
|
|
|
|
# Assign observation to person
|
|
observation.assigned_to = person
|
|
observation.assigned_at = timezone.now()
|
|
observation.save(update_fields=["assigned_to", "assigned_at"])
|
|
|
|
# Send notification
|
|
if person.email:
|
|
NotificationService.send_email(
|
|
email=person.email,
|
|
subject=f"Observation Assigned - {observation.tracking_code}",
|
|
message=f"You have been assigned to observation #{observation.tracking_code}.",
|
|
html_message=f"""
|
|
<div style="font-family: 'Segoe UI', Tahoma, sans-serif; max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
|
{get_email_header_html()}
|
|
<div style="padding: 30px;">
|
|
<p>You have been assigned to observation <strong>#{observation.tracking_code}</strong>.</p>
|
|
<p><strong>Title:</strong> {observation.title or 'N/A'}</p>
|
|
{f'<p><strong>Note:</strong> {note}</p>' if note else ''}
|
|
<a href="https://{request.get_host()}/observations/{observation.pk}/" style="display: inline-block; padding: 12px 24px; background: #005696; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0;">View Observation</a>
|
|
</div>
|
|
</div>
|
|
""",
|
|
related_object=observation,
|
|
)
|
|
|
|
message = f"Observation 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.select_related("champion", "manager").get(pk=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
|
|
from apps.notifications.services import NotificationService
|
|
|
|
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)
|
|
|
|
# Send to department
|
|
observation.assigned_department = department
|
|
observation.forwarded_to_dept_at = timezone.now()
|
|
observation.sent_to_department = True
|
|
observation.sent_to_department_at = timezone.now()
|
|
|
|
sla_config = observation.get_sla_config()
|
|
if sla_config and sla_config.dept_response_hours:
|
|
from datetime import timedelta
|
|
observation.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours)
|
|
observation.dept_response_is_overdue = False
|
|
observation.dept_response_reminder_sent_at = None
|
|
observation.dept_response_second_reminder_sent_at = None
|
|
observation.dept_response_escalated_at = None
|
|
|
|
# Notify champion + manager (email + SMS) with the observation link
|
|
notified = []
|
|
link = f"https://{request.get_host()}/observations/{observation.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"Observation Sent to Department - {observation.tracking_code}",
|
|
message=f"Observation #{observation.tracking_code} has been sent to your department ({department.name}).\n\n{link}",
|
|
related_object=observation,
|
|
)
|
|
except Exception:
|
|
pass
|
|
if target.get("phone"):
|
|
try:
|
|
NotificationService.send_sms(
|
|
target["phone"],
|
|
f"PX360: Observation #{observation.tracking_code} sent to {department.name}. Review: {link}",
|
|
related_object=observation,
|
|
)
|
|
except Exception:
|
|
pass
|
|
notified.append(f"{display_name} ({target['label']})")
|
|
|
|
message = f"Observation sent to {department.get_localized_name()} — {', '.join(notified)}."
|
|
|
|
old_status_before_send = observation.status
|
|
observation.contact_status = "contacted"
|
|
observation.contact_status_at = timezone.now()
|
|
observation.contact_status_by = user
|
|
|
|
observation.save()
|
|
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=note or f"Sent to {recipient_type}",
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
return JsonResponse({
|
|
"success": True,
|
|
"message": message,
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in observation_send_to: {str(e)}")
|
|
return JsonResponse({
|
|
"success": False,
|
|
"error": str(_("An error occurred while sending the observation.")),
|
|
}, status=500)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def observation_department_response(request, pk):
|
|
"""
|
|
Submit a department response for an observation.
|
|
"""
|
|
observation = get_object_or_404(Observation, 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()
|
|
or (user.is_champion() and observation.assigned_department == user.department)
|
|
or (user.is_department_manager() and observation.assigned_department == user.department)
|
|
):
|
|
messages.error(request, "You don't have permission to respond to this observation.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
if request.method == "POST":
|
|
response_en = request.POST.get("response_en", "").strip()
|
|
response_ar = request.POST.get("response_ar", "").strip()
|
|
response = response_en or response_ar
|
|
|
|
if not response:
|
|
messages.error(request, "Please enter a response in at least one language.")
|
|
return redirect("observations:observation_department_response", pk=pk)
|
|
|
|
observation.department_response_en = response_en
|
|
observation.department_response_ar = response_ar
|
|
observation.department_responded_at = timezone.now()
|
|
observation.department_responded_by = request.user
|
|
observation.dept_response_is_overdue = False
|
|
observation.save()
|
|
|
|
# Generate AI summary
|
|
try:
|
|
from apps.core.ai_service import AIService
|
|
import json
|
|
|
|
prompt = f"""You are an AI assistant for a hospital patient experience system. Summarize the following department response to a staff observation in 2-3 concise sentences.
|
|
|
|
Observation: {observation.description[:500]}
|
|
Department response: {response[:500]}
|
|
|
|
Generate a JSON response with:
|
|
- "summary_en": A brief summary of the department's response in English (2-3 sentences)
|
|
- "summary_ar": The same summary translated to Modern Standard Arabic"""
|
|
|
|
result = AIService.chat_completion(
|
|
prompt=prompt,
|
|
response_format="json_object",
|
|
)
|
|
parsed = json.loads(result)
|
|
observation.department_response_summary_en = parsed.get("summary_en", "")
|
|
observation.department_response_summary_ar = parsed.get("summary_ar", "")
|
|
observation.save(update_fields=["department_response_summary_en", "department_response_summary_ar"])
|
|
except Exception as e:
|
|
logger.warning(f"AI summary of department response failed: {e}")
|
|
|
|
ObservationStatusLog.objects.create(
|
|
observation=observation,
|
|
from_status=observation.status,
|
|
to_status=observation.status,
|
|
changed_by=user,
|
|
comment=f"Department response submitted by {user.get_full_name()}",
|
|
)
|
|
|
|
# Create note
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Department response submitted by {user.get_full_name()}",
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
messages.success(request, "Department response submitted successfully.")
|
|
# Prefer a user-supplied relative next URL (e.g. back to the department
|
|
# observation detail page) over the default department landing page.
|
|
# Block protocol-relative URLs (``//host/`` or ``/\\host/``) to prevent
|
|
# open-redirect abuse.
|
|
next_url = request.POST.get("next")
|
|
if not (
|
|
next_url
|
|
and next_url.startswith("/")
|
|
and not next_url.startswith("//")
|
|
and not next_url.startswith("/\\")
|
|
):
|
|
next_url = None
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
from django.http import JsonResponse
|
|
from django.urls import reverse
|
|
if next_url:
|
|
redirect_url = next_url
|
|
elif user.department:
|
|
redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk})
|
|
else:
|
|
redirect_url = reverse("observations:observation_detail", kwargs={"pk": pk})
|
|
return JsonResponse({"success": True, "redirect_url": redirect_url})
|
|
if next_url:
|
|
return redirect(next_url)
|
|
if user.department:
|
|
return redirect("organizations:department_detail", pk=user.department.pk)
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
context = {
|
|
"observation": observation,
|
|
}
|
|
return render(request, "observations/observation_department_response.html", context)
|
|
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_send_dept_response_reminder(request, pk):
|
|
observation = get_object_or_404(Observation, 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("observations:observation_detail", pk=pk)
|
|
|
|
if observation.department_responded_at:
|
|
messages.warning(request, "Department has already responded.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
dept = observation.assigned_department
|
|
if not dept:
|
|
messages.error(request, "No department assigned.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
reminder_type = request.POST.get("reminder_type", "first")
|
|
|
|
if reminder_type == "first" and observation.dept_response_reminder_sent_at:
|
|
messages.warning(request, "First reminder already sent.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
if reminder_type == "second" and observation.dept_response_second_reminder_sent_at:
|
|
messages.warning(request, "Second reminder already sent.")
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
if reminder_type == "second" and not observation.dept_response_reminder_sent_at:
|
|
messages.warning(request, "Please send the first reminder before the second.")
|
|
return redirect("observations:observation_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("observations:observation_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":
|
|
observation.dept_response_reminder_sent_at = timezone.now()
|
|
else:
|
|
observation.dept_response_second_reminder_sent_at = timezone.now()
|
|
observation.save()
|
|
|
|
from apps.observations.tasks import send_observation_reminder_email
|
|
send_observation_reminder_email.delay(str(observation.id), reminder_type, recipients)
|
|
|
|
ObservationNote.objects.create(
|
|
observation=observation,
|
|
note=f"Manual {reminder_type} reminder sent by {user.get_full_name()}",
|
|
created_by=user,
|
|
is_internal=True,
|
|
)
|
|
|
|
messages.success(request, f"Reminder sent to {len(recipients)} recipient(s).")
|
|
except Exception as e:
|
|
logger.error(f"Failed to send reminder: {e}")
|
|
messages.error(request, "Failed to send reminder.")
|
|
|
|
return redirect("observations:observation_detail", pk=pk)
|
|
|
|
|
|
# =============================================================================
|
|
# CATEGORY MANAGEMENT VIEWS
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
def category_list(request):
|
|
"""
|
|
List observation categories.
|
|
"""
|
|
if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied
|
|
categories = ObservationCategory.objects.all().order_by("sort_order", "name_en")
|
|
|
|
context = {
|
|
"categories": categories,
|
|
}
|
|
|
|
return render(request, "observations/category_list.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def category_create(request):
|
|
"""
|
|
Create new observation category.
|
|
"""
|
|
if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied
|
|
if request.method == "POST":
|
|
form = ObservationCategoryForm(request.POST)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Category created successfully.")
|
|
return redirect("observations:category_list")
|
|
else:
|
|
form = ObservationCategoryForm()
|
|
|
|
context = {
|
|
"form": form,
|
|
"title": "Create Category",
|
|
}
|
|
|
|
return render(request, "observations/category_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["GET", "POST"])
|
|
def category_edit(request, pk):
|
|
"""
|
|
Edit observation category.
|
|
"""
|
|
if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied
|
|
category = get_object_or_404(ObservationCategory, pk=pk)
|
|
|
|
if request.method == "POST":
|
|
form = ObservationCategoryForm(request.POST, instance=category)
|
|
if form.is_valid():
|
|
form.save()
|
|
messages.success(request, "Category updated successfully.")
|
|
return redirect("observations:category_list")
|
|
else:
|
|
form = ObservationCategoryForm(instance=category)
|
|
|
|
context = {
|
|
"form": form,
|
|
"category": category,
|
|
"title": "Edit Category",
|
|
}
|
|
|
|
return render(request, "observations/category_form.html", context)
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def category_delete(request, pk):
|
|
"""
|
|
Delete observation category.
|
|
"""
|
|
if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()):
|
|
from django.core.exceptions import PermissionDenied
|
|
raise PermissionDenied
|
|
category = get_object_or_404(ObservationCategory, pk=pk)
|
|
|
|
# Check if category is in use
|
|
if category.observations.exists():
|
|
messages.error(request, "Cannot delete category that is in use.")
|
|
return redirect("observations:category_list")
|
|
|
|
category.delete()
|
|
messages.success(request, "Category deleted successfully.")
|
|
return redirect("observations:category_list")
|
|
|
|
|
|
# =============================================================================
|
|
# AJAX/API HELPERS
|
|
# =============================================================================
|
|
|
|
|
|
@login_required
|
|
def get_users_by_department(request):
|
|
"""
|
|
Get users for a department (AJAX).
|
|
"""
|
|
department_id = request.GET.get("department_id")
|
|
if not department_id:
|
|
return JsonResponse({"users": []})
|
|
|
|
users = User.objects.filter(is_active=True, department_id=department_id).values(
|
|
"id", "first_name", "last_name", "email"
|
|
)
|
|
|
|
return JsonResponse(
|
|
{
|
|
"users": [
|
|
{
|
|
"id": str(u["id"]),
|
|
"name": f"{u['first_name']} {u['last_name']}",
|
|
"email": u["email"],
|
|
}
|
|
for u in users
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# UTILITY FUNCTIONS
|
|
# =============================================================================
|
|
|
|
|
|
def get_client_ip(request):
|
|
"""
|
|
Get client IP address from request.
|
|
"""
|
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
if x_forwarded_for:
|
|
ip = x_forwarded_for.split(",")[0].strip()
|
|
else:
|
|
ip = request.META.get("REMOTE_ADDR")
|
|
return ip
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_soft_delete(request, pk):
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to delete observations."))
|
|
observation.soft_delete(user=request.user)
|
|
messages.success(request, _("Observation moved to trash."))
|
|
return redirect("observations:observation_list")
|
|
|
|
|
|
@login_required
|
|
@require_http_methods(["POST"])
|
|
def observation_restore(request, pk):
|
|
observation = get_object_or_404(Observation.all_objects, pk=pk, is_deleted=True)
|
|
if not (
|
|
request.user.is_px_admin() or request.user.is_hospital_admin()
|
|
or request.user.is_px_management() or request.user.is_px_employee()
|
|
):
|
|
return HttpResponseForbidden(_("You don't have permission to restore observations."))
|
|
observation.restore()
|
|
messages.success(request, _("Observation restored successfully."))
|
|
return redirect("observations:observation_list")
|
|
|
|
|
|
def observation_respond_with_token(request, pk, token):
|
|
"""
|
|
Public-facing form for department staff to submit response to an observation.
|
|
Does NOT require authentication. Validates token and checks validity.
|
|
"""
|
|
from apps.core.ai_service import AIService
|
|
from apps.notifications.services import NotificationService
|
|
from apps.core.utils import build_public_track_url
|
|
|
|
observation = get_object_or_404(Observation, pk=pk)
|
|
|
|
if observation.response_token != token or not observation.response_token:
|
|
return render(request, "observations/response_token_invalid.html", {"observation": observation})
|
|
|
|
if observation.response_token_used:
|
|
return render(request, "observations/response_already_submitted.html", {"observation": observation})
|
|
|
|
if request.method == "POST":
|
|
response_en = request.POST.get("response_en", "").strip()
|
|
response_ar = request.POST.get("response_ar", "").strip()
|
|
response = response_en or response_ar
|
|
|
|
if not response:
|
|
return render(request, "observations/response_form_token.html", {
|
|
"observation": observation,
|
|
"error": "Please enter a response in at least one language.",
|
|
})
|
|
|
|
observation.department_response_en = response_en
|
|
observation.department_response_ar = response_ar
|
|
observation.department_responded_at = timezone.now()
|
|
observation.dept_response_is_overdue = False
|
|
observation.response_token_used = True
|
|
observation.save()
|
|
|
|
# AI summary
|
|
try:
|
|
import json
|
|
prompt = f"""Summarize the following department response to a staff observation in 2-3 concise sentences.
|
|
|
|
Observation: {observation.description[:500]}
|
|
Department response: {response[:500]}
|
|
|
|
Generate JSON with "summary_en" and "summary_ar"."""
|
|
result = AIService.chat_completion(prompt=prompt, response_format="json_object")
|
|
parsed = json.loads(result)
|
|
observation.department_response_summary_en = parsed.get("summary_en", "")
|
|
observation.department_response_summary_ar = parsed.get("summary_ar", "")
|
|
observation.save(update_fields=["department_response_summary_en", "department_response_summary_ar"])
|
|
except Exception:
|
|
pass
|
|
|
|
# Notify reporter
|
|
track_url = build_public_track_url("observation", observation.tracking_code)
|
|
if observation.reporter_phone:
|
|
try:
|
|
NotificationService.send_sms(
|
|
phone=observation.reporter_phone,
|
|
message=f"PX360: Your observation {observation.tracking_code} has been responded to. View: {track_url}",
|
|
related_object=observation,
|
|
)
|
|
except Exception:
|
|
pass
|
|
if observation.reporter_email:
|
|
try:
|
|
NotificationService.send_email(
|
|
email=observation.reporter_email,
|
|
subject=f"PX360: Response to Your Observation {observation.tracking_code}",
|
|
message=f"Your observation has been responded to.\n\nView: {track_url}",
|
|
related_object=observation,
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return render(request, "observations/response_success_token.html", {"observation": observation})
|
|
|
|
return render(request, "observations/response_form_token.html", {"observation": observation})
|
|
|
|
@login_required
|
|
def observation_pdf(request, pk):
|
|
from apps.core.pdf_utils import generate_letterhead_pdf
|
|
obj = get_object_or_404(Observation, id=pk)
|
|
return generate_letterhead_pdf(
|
|
'observations/observation_pdf.html',
|
|
{'object': obj},
|
|
f'observation_{obj.tracking_code}.pdf',
|
|
obj=obj,
|
|
)
|