HH/apps/observations/views.py
ismail e5705a1b1c
All checks were successful
Build and Push Docker Image / build (push) Successful in 2m8s
fix: 3 cross-hospital data isolation violations (0 violations after fix)
Isolation test: HH-N admin vs E2E-HOSP data across 5 scenarios.
Found + fixed 3 real isolation gaps:

1. observation_list LEAKED unassigned observations across hospitals —
   the filter Q(assigned_department__hospital=X) | Q(assigned_department__isnull=True)
   showed ALL unassigned observations globally. Fixed: add hospital= filter
   to the isnull branch.

2. complaint_add_note allowed cross-hospital note creation —
   ComplaintService.add_note had no hospital check. Any authenticated user
   from any hospital could add notes to any complaint. Fixed: added hospital
   isolation check (same hospital or px_admin).

3. observation_detail accessible cross-hospital when assigned_department is null —
   the RBAC check only ran if observation.assigned_department was set.
   Fixed: added fallback hospital check for observations with no department.

Result: 16 PASS, 0 FAIL, 0 isolation violations.
Tested: list isolation (8 modules), detail isolation (3), write isolation (2),
px_admin hospital switching, observation no-dept edge case.
2026-06-18 21:17:37 +03:00

2177 lines
88 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", ""),
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="",
location_text="",
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")
assigned_user = form.cleaned_data.get("assigned_to")
section = form.cleaned_data.get("section")
if assigned_dept:
observation.assigned_department = assigned_dept
if assigned_user:
observation.assigned_to = assigned_user
observation.status = ObservationStatus.IN_PROGRESS
ObservationStatusLog.objects.create(
observation=observation,
from_status=ObservationStatus.OPEN,
to_status=ObservationStatus.IN_PROGRESS,
comment="Auto-assigned during creation",
)
if section:
observation.section = section
observation.save()
comm_req_id = request.POST.get("comm_req")
if comm_req_id:
try:
from apps.px_sources.models import CommunicationRequest
cr = CommunicationRequest.objects.get(pk=comm_req_id)
cr.link_to_record(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
departments = Department.objects.filter(status="active")
from apps.core.utils import get_assignable_users
assignable_users = get_assignable_users(user.hospital)
if user.hospital:
departments = departments.filter(hospital=user.hospital)
# 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(),
"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_review_dept_response": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(),
"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(),
"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
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()):
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()):
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(["POST"])
def observation_update_satisfaction(request, pk):
"""Update observation satisfaction."""
observation = get_object_or_404(Observation, pk=pk)
satisfaction = request.POST.get("satisfaction", "").strip()
if satisfaction in ("satisfied", "neutral", "dissatisfied", "no_response"):
observation.satisfaction = satisfaction
observation.satisfaction_set_at = timezone.now()
observation.save(update_fields=["satisfaction", "satisfaction_set_at"])
messages.success(request, _("Satisfaction updated."))
return redirect("observations:observation_detail", pk=pk)
@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)
if not department.champion and not department.manager:
return JsonResponse({
"success": False,
"error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")),
}, status=400)
contact_person_id = request.POST.get("contact_person_id")
if not contact_person_id:
return JsonResponse({
"success": False,
"error": str(_("Please select a contact person.")),
}, status=400)
contact_info = department.is_valid_contact_person(contact_person_id)
if not contact_info:
return JsonResponse({
"success": False,
"error": str(_("Selected person is not a role holder in this department.")),
}, 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
message = f"Observation sent to {department.get_localized_name()}{contact_info['name']} ({contact_info['role_label']})."
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,
)
# Send department notification if applicable
if recipient_type == "department":
try:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_observation_department_assigned(
department, observation, context_note_en=note, context_note_ar="",
)
except Exception as e:
logger.warning(f"Failed to send department notification: {e}")
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_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.dept_response_acceptance_status = "pending"
observation.dept_response_accepted_by = None
observation.dept_response_accepted_at = None
observation.dept_response_acceptance_notes = ""
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,
)
try:
from apps.notifications.services import NotificationService, get_email_header_html
from apps.core.utils import build_public_track_url
track_url = build_public_track_url("observation", observation.tracking_code)
if observation.reporter_phone:
NotificationService.send_sms(
phone=observation.reporter_phone,
message=f"PX360: Your observation {observation.tracking_code} has been responded to. View details: {track_url}",
related_object=observation,
metadata={"notification_type": "observation_department_response"},
)
if observation.reporter_email:
email_subject = f"PX360: Response to Your Observation {observation.tracking_code}"
email_body = (
f"Dear Valued Reporter,\n\n"
f"Your observation {observation.tracking_code} has been responded to.\n\n"
f"To view the full response, please visit:\n{track_url}\n\n"
f"Thank you for your contribution.\n\n"
f"Tracking Code: {observation.tracking_code}\n"
f"This is an automated message from PX 360."
)
NotificationService.send_email(
email=observation.reporter_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;">Response to Your Observation</h2>
<p style="margin: 0 0 12px 0;">Dear Valued Reporter,</p>
<p style="margin: 0 0 12px 0;">Your observation <strong>{observation.tracking_code}</strong> has been responded to.</p>
<div style="text-align: center; margin: 20px 0;">
<a href="{track_url}" style="background: #005696; color: white; padding: 10px 24px; border-radius: 8px; text-decoration: none; font-weight: 600;">View Response</a>
</div>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Tracking Code: {observation.tracking_code}</p>
<p style="margin: 0 0 6px 0; color: #6b7280; font-size: 13px;">Thank you for your contribution.</p>
</div>
</div>
""",
related_object=observation,
metadata={"notification_type": "observation_department_response_email"},
)
except Exception as e:
logger.warning(f"Failed to send observation response notification: {e}")
messages.success(request, "Department response submitted successfully.")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
from django.http import JsonResponse
from django.urls import reverse
redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) if user.department else reverse("observations:observation_detail", kwargs={"pk": pk})
return JsonResponse({"success": True, "redirect_url": redirect_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_review_dept_response(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 review department responses.")
return redirect("observations:observation_detail", pk=pk)
if not observation.department_responded_at:
messages.error(request, "No department response to review.")
return redirect("observations:observation_detail", pk=pk)
status = request.POST.get("acceptance_status")
if status not in ("acceptable", "not_acceptable"):
messages.error(request, "Invalid acceptance status.")
return redirect("observations:observation_detail", pk=pk)
notes = request.POST.get("acceptance_notes", "").strip()
if status == "not_acceptable":
observation.dept_response_acceptance_status = "not_acceptable"
observation.dept_response_accepted_by = user
observation.dept_response_accepted_at = timezone.now()
observation.dept_response_acceptance_notes = notes
observation.department_response_en = ""
observation.department_response_ar = ""
observation.department_response_summary_en = ""
observation.department_response_summary_ar = ""
observation.department_responded_at = None
observation.department_responded_by = None
observation.save(
update_fields=[
"dept_response_acceptance_status",
"dept_response_accepted_by",
"dept_response_accepted_at",
"dept_response_acceptance_notes",
"department_response_en",
"department_response_ar",
"department_response_summary_en",
"department_response_summary_ar",
"department_responded_at",
"department_responded_by",
]
)
dept = observation.assigned_department
if dept and dept.champion and dept.champion.user and dept.champion.user.email:
try:
from apps.notifications.services import NotificationService, get_email_header_html
NotificationService.send_email(
email=dept.champion.user.email,
subject=f"Action Required: Observation {observation.tracking_code} - Response Rejected",
message=(
f"Your department's response for observation {observation.tracking_code} has been rejected.\n\n"
f"Reason: {notes}\n\n"
f"Please revise and resubmit your response."
),
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;">Response Rejected - Observation {observation.tracking_code}</h2>
<p style="margin: 0 0 12px 0;">Your department's response for observation <strong>{observation.tracking_code}</strong> has been rejected.</p>
<div style="background: #fef2f2; padding: 12px; border-radius: 6px; margin: 0 0 12px 0;">
<p style="margin: 0;"><strong>Reason:</strong> {notes}</p>
</div>
<p style="margin: 0; color: #6b7280;">Please revise and resubmit your response.</p>
</div>
</div>
""",
related_object=observation,
)
except Exception:
import logging
logging.getLogger(__name__).exception("Failed to send observation dept response rejection email")
ObservationNote.objects.create(
observation=observation,
note=f"Department response rejected by {user.get_full_name()}. Reason: {notes}",
created_by=user,
is_internal=True,
)
messages.success(request, "Department response rejected. The department has been notified to resubmit.")
else:
observation.dept_response_acceptance_status = status
observation.dept_response_accepted_by = user
observation.dept_response_accepted_at = timezone.now()
observation.dept_response_acceptance_notes = notes
observation.save(
update_fields=[
"dept_response_acceptance_status",
"dept_response_accepted_by",
"dept_response_accepted_at",
"dept_response_acceptance_notes",
]
)
ObservationStatusLog.objects.create(
observation=observation,
from_status=observation.status,
to_status=observation.status,
changed_by=user,
comment=f"Department response marked as {status} by {user.get_full_name()}",
)
ObservationNote.objects.create(
observation=observation,
note=f"Department response review: {status}. {notes}",
created_by=user,
is_internal=True,
)
messages.success(request, f"Department response marked as {status}.")
return redirect("observations:observation_detail", pk=pk)
@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")
try:
from apps.notifications.services import NotificationService, get_email_header_html
recipients = []
if dept.champion and dept.champion.user and dept.champion.user.email:
recipients.append(dept.champion.user)
for recipient in recipients:
NotificationService.send_email(
email=recipient.email,
subject=f"Reminder: Observation {observation.tracking_code} - Response Required",
message=f"This is a reminder that observation {observation.tracking_code} is awaiting your department's response. Please submit your response as soon as possible.",
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;">Reminder: Response Required</h2>
<p style="margin: 0 0 12px 0;">This is a reminder that observation <strong>{observation.tracking_code}</strong> is awaiting your department's response.</p>
<p style="margin: 0; color: #6b7280;">Please submit your response as soon as possible.</p>
</div>
</div>
""",
related_object=observation,
)
if reminder_type == "first":
observation.dept_response_reminder_sent_at = timezone.now()
else:
observation.dept_response_second_reminder_sent_at = timezone.now()
observation.save()
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
@permission_required("observations.manage_categories", raise_exception=True)
def category_list(request):
"""
List observation categories.
"""
categories = ObservationCategory.objects.all().order_by("sort_order", "name_en")
context = {
"categories": categories,
}
return render(request, "observations/category_list.html", context)
@login_required
@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["GET", "POST"])
def category_create(request):
"""
Create new observation category.
"""
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
@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["GET", "POST"])
def category_edit(request, pk):
"""
Edit observation category.
"""
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
@permission_required("observations.manage_categories", raise_exception=True)
@require_http_methods(["POST"])
def category_delete(request, pk):
"""
Delete observation category.
"""
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.dept_response_acceptance_status = "pending"
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})