2064 lines
83 KiB
Python
2064 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),
|
|
"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)
|
|
):
|
|
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,
|
|
)
|