""" 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.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, ) 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