""" 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"""
You have been assigned to observation #{observation.tracking_code}.
Title: {observation.title or 'N/A'}
{f'Note: {note}
' if note else ''} """, 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")