HH/apps/observations/views.py
ismail c5f76b3855
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
updates
2026-05-11 14:45:30 +03:00

1538 lines
57 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,
)
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."""
stages = []
if observation.created_at:
stages.append({
"label": _("Created"),
"timestamp": observation.created_at,
"color": "bg-slate-400",
})
if observation.triaged_at:
stages.append({
"label": _("Triaged"),
"timestamp": observation.triaged_at,
"duration_from_prev": _format_duration(observation.created_at, observation.triaged_at),
"color": "bg-indigo-500",
})
if observation.activated_at:
stages.append({
"label": _("Activated"),
"timestamp": observation.activated_at,
"duration_from_prev": _format_duration(observation.triaged_at or observation.created_at, observation.activated_at),
"color": "bg-blue-500",
})
if observation.forwarded_to_dept_at:
stages.append({
"label": _("Forwarded to Department"),
"timestamp": observation.forwarded_to_dept_at,
"duration_from_prev": _format_duration(observation.activated_at or observation.triaged_at, observation.forwarded_to_dept_at),
"color": "bg-purple-500",
})
if observation.department_responded_at:
stages.append({
"label": _("Department Responded"),
"timestamp": observation.department_responded_at,
"duration_from_prev": _format_duration(observation.forwarded_to_dept_at or observation.activated_at, observation.department_responded_at),
"color": "bg-amber-500",
})
if observation.resolved_at:
stages.append({
"label": _("Resolved"),
"timestamp": observation.resolved_at,
"duration_from_prev": _format_duration(observation.department_responded_at or observation.forwarded_to_dept_at, observation.resolved_at),
"color": "bg-green-500",
})
if observation.closed_at:
stages.append({
"label": _("Closed"),
"timestamp": observation.closed_at,
"duration_from_prev": _format_duration(observation.resolved_at or observation.department_responded_at, observation.closed_at),
"color": "bg-emerald-600",
})
return stages
# =============================================================================
# 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")
from apps.organizations.models import Location, MainSection, SubSection
location = None
main_section = None
subsection = None
loc_id = request.POST.get("location")
sec_id = request.POST.get("main_section")
sub_id = request.POST.get("subsection")
if loc_id:
try:
location = Location.objects.get(id=loc_id)
except Location.DoesNotExist:
pass
if sec_id:
try:
main_section = MainSection.objects.get(id=sec_id)
except MainSection.DoesNotExist:
pass
if sub_id:
try:
subsection = SubSection.objects.get(internal_id=sub_id)
except SubSection.DoesNotExist:
pass
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
assigned_department = None
if location and hasattr(location, 'department_id') and location.department_id:
assigned_department = location.department
observation = ObservationService.create_observation(
description=form.cleaned_data["description"],
severity=form.cleaned_data.get("severity", "medium"),
category=form.cleaned_data.get("category"),
title=form.cleaned_data.get("title", ""),
location_text=form.cleaned_data.get("location_text", ""),
location=location,
main_section=main_section,
subsection=subsection,
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,
assigned_department=assigned_department,
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,
"categories": ObservationCategory.objects.filter(is_active=True).order_by("sort_order"),
"hospitals": Hospital.objects.filter(is_active=True).order_by("name_en"),
}
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.")
public_timeline = []
if observation:
for log in observation.status_logs.all().order_by("-created_at"):
public_timeline.append(
{
"type": "status_change",
"title": _("Status Updated"),
"comment": log.comment,
"created_at": log.created_at,
"from_status": log.from_status,
"to_status": log.to_status,
}
)
for note in observation.notes.filter(is_internal=False).order_by("-created_at"):
public_timeline.append(
{
"type": "note",
"title": _("Update Received"),
"comment": note.note,
"created_at": note.created_at,
}
)
public_timeline.sort(key=lambda x: x["created_at"], reverse=True)
context = {
"observation": observation,
"public_timeline": public_timeline,
"error_message": error_message,
"tracking_code": tracking_code,
}
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.FILES, request=request)
if form.is_valid():
try:
client_ip = get_client_ip(request)
user_agent = request.META.get("HTTP_USER_AGENT", "")
user = request.user
attachments = request.FILES.getlist("attachments")
observation = ObservationService.create_observation(
description=form.cleaned_data["description"],
severity=form.cleaned_data.get("severity") or request.POST.get("severity", "medium"),
category=form.cleaned_data.get("category"),
title=form.cleaned_data.get("title", ""),
location_text=form.cleaned_data.get("location_text", ""),
location=form.cleaned_data.get("location"),
main_section=form.cleaned_data.get("main_section"),
subsection=form.cleaned_data.get("subsection"),
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,
attachments=attachments,
hospital=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")
if assigned_dept:
observation.assigned_department = assigned_dept
if assigned_user:
observation.assigned_to = assigned_user
observation.status = ObservationStatus.ASSIGNED
ObservationStatusLog.objects.create(
observation=observation,
from_status=ObservationStatus.NEW,
to_status=ObservationStatus.ASSIGNED,
comment="Auto-assigned during creation",
)
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) | Q(assigned_department__isnull=True)
)
elif user.is_hospital_admin() and user.hospital:
queryset = queryset.filter(Q(assigned_department__hospital=user.hospital) | Q(assigned_department__isnull=True))
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)
# Ordering
order_by = request.GET.get("order_by", "-created_at")
queryset = queryset.order_by(order_by)
# Pagination
page_size = int(request.GET.get("page_size", 25))
paginator = Paginator(queryset, page_size)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
# 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)
# Statistics
stats = ObservationService.get_statistics()
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",
"location", "main_section", "subsection", "hospital",
"taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification",
).prefetch_related("attachments", "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")
# Get timeline (combine status logs and notes)
status_logs = list(observation.status_logs.all())
notes = list(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(),
"can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management(),
"can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or (getattr(user, 'is_department_respondent', lambda: False)() and observation.assigned_department == user.department),
"can_review_dept_response": user.is_px_admin() or user.is_hospital_admin(),
"can_send_reminder": user.is_px_admin() or user.is_hospital_admin(),
"can_delete": user.is_px_admin() or user.is_hospital_admin(),
"linked_rcas": linked_rcas,
}
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()):
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()):
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
if old_status in ("resolved", "closed"):
observation.status = ObservationStatus.IN_PROGRESS
observation.resolved_at = None
observation.resolved_by = None
observation.closed_at = None
observation.closed_by = None
reopened = True
observation.save()
ObservationStatusLog.objects.create(
observation=observation,
from_status=old_status,
to_status=observation.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 ""),
)
ObservationNote.objects.create(
observation=observation,
note=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 ""),
created_by=request.user,
is_internal=True,
)
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_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()):
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")
observation.status = ObservationStatus.IN_PROGRESS
observation.resolved_at = None
observation.resolved_by = None
observation.closed_at = None
observation.closed_by = None
observation.save()
ObservationStatusLog.objects.create(
observation=observation,
from_status=old_status,
to_status=ObservationStatus.IN_PROGRESS,
changed_by=user,
comment=note_text,
)
ObservationNote.objects.create(
observation=observation,
note=note_text,
created_by=user,
is_internal=True,
)
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(["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()):
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()
):
messages.error(request, _("You don't have permission to send observations to departments."))
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.get(pk=department_id, status="active")
except Department.DoesNotExist:
messages.error(request, _("Department not found."))
return redirect("observations:observation_detail", pk=pk)
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()
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
if observation.status in ("new", "triaged"):
observation.status = "contacted"
observation.save()
# Create status log
ObservationStatusLog.objects.create(
observation=observation,
from_status="",
to_status="contacted",
changed_by=user,
comment=combined_note or f"Observation sent to {department.get_localized_name()} for response",
)
# 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:
from apps.notifications.settings_service import NotificationServiceWithSettings
NotificationServiceWithSettings.send_observation_department_assigned(
department, observation, context_note_en=note_en, context_note_ar=note_ar,
recipient_type=recipient_type,
)
if department.respondent and department.respondent.user and department.respondent.user.email:
from apps.notifications.services import NotificationService
NotificationService.send_email(
email=department.respondent.user.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. Please submit your response before the deadline: {observation.dept_response_sla_due_at}",
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 respondents have been notified.")
% {"dept": department.get_localized_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
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()
):
return JsonResponse({
"success": False,
"error": str(_("You don't have permission to send this observation.")),
}, status=403)
recipient_type = request.POST.get("recipient_type", "department")
note = request.POST.get("note", "").strip()
try:
if recipient_type == "person":
person_id = request.POST.get("person_id")
if not person_id:
return JsonResponse({
"success": False,
"error": 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"""
<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 ''}
<p><a href="https://{request.get_host()}/observations/{observation.pk}/">View Observation</a></p>
""",
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.get(pk=department_id, status="active")
except Department.DoesNotExist:
return JsonResponse({
"success": False,
"error": str(_("Department not found.")),
}, status=400)
# Send to department
observation.assigned_department = department
observation.forwarded_to_dept_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()}."
# Change status to contacted if new or triaged
if observation.status in ("new", "triaged"):
observation.status = "contacted"
observation.save()
# Create status log
ObservationStatusLog.objects.create(
observation=observation,
from_status="",
to_status="contacted",
changed_by=user,
comment=note or f"Observation sent to {recipient_type}",
)
# 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}")
# Create status log
ObservationStatusLog.objects.create(
observation=observation,
from_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.")
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()):
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()
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="",
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()):
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
recipients = []
if dept.respondent and dept.respondent.user and dept.respondent.user.email:
recipients.append(dept.respondent.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.",
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()):
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()):
return HttpResponseForbidden(_("You don't have permission to restore observations."))
observation.restore()
messages.success(request, _("Observation restored successfully."))
return redirect("observations:observation_list")