HH/apps/observations/views.py
2026-04-08 17:13:35 +03:00

797 lines
28 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 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.organizations.models import Department
from .forms import (
ConvertToActionForm,
ObservationCategoryForm,
ObservationInternalForm,
ObservationNoteForm,
ObservationPublicForm,
ObservationStatusForm,
ObservationTrackForm,
ObservationTriageForm,
)
from .models import (
Observation,
ObservationAttachment,
ObservationCategory,
ObservationNote,
ObservationStatus,
ObservationStatusLog,
)
from .services import ObservationService
logger = logging.getLogger(__name__)
# =============================================================================
# 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:
# Get client info
client_ip = get_client_ip(request)
user_agent = request.META.get("HTTP_USER_AGENT", "")
# Handle file uploads
attachments = request.FILES.getlist("attachments")
# Create observation using service
observation = ObservationService.create_observation(
description=form.cleaned_data["description"],
severity=form.cleaned_data["severity"],
category=form.cleaned_data.get("category"),
title=form.cleaned_data.get("title", ""),
location_text=form.cleaned_data.get("location_text", ""),
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,
)
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()
context = {
"form": form,
"categories": ObservationCategory.objects.filter(is_active=True).order_by("sort_order"),
}
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.
"""
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["severity"],
category=form.cleaned_data.get("category"),
title=form.cleaned_data.get("title", ""),
location_text=form.cleaned_data.get("location_text", ""),
incident_datetime=form.cleaned_data.get("incident_datetime"),
reporter_staff_id=user.staff_id or "",
reporter_name=user.get_full_name(),
reporter_phone="",
reporter_email=user.email,
client_ip=client_ip,
user_agent=user_agent,
attachments=attachments,
)
observation.source = "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()
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:
form = ObservationInternalForm(request=request)
context = {
"form": form,
}
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)
assignable_users = User.objects.filter(is_active=True)
if not user.is_px_admin() and user.hospital:
assignable_users = assignable_users.filter(hospital=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"
).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)
# 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")
assignable_users = User.objects.filter(is_active=True)
if user.hospital:
departments = departments.filter(hospital=user.hospital)
assignable_users = assignable_users.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,
"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(),
"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_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)
# =============================================================================
# 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