""" Observations views - Public and internal views for observation management. Public views (no login required): - observation_create_public: Submit new observation - observation_submitted: Success page with tracking code - observation_track: Track observation by code Internal views (login required): - observation_list: List all observations with filters - observation_detail: View observation details - observation_triage: Triage observation - observation_change_status: Change observation status - observation_add_note: Add internal note - observation_convert_to_action: Convert to PX Action - category_list: Manage categories - category_create/edit/delete: Category CRUD """ import logging from django.contrib import messages from django.contrib.auth.decorators import login_required, permission_required from django.core.paginator import Paginator from django.db.models import Q from django.http import HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.utils.translation import gettext as _ from django.views.decorators.http import require_http_methods from apps.accounts.models import User from apps.accounts.services import StaffActivityService from apps.organizations.models import Department from .forms import ( ConvertToActionForm, ObservationCategoryForm, ObservationDepartmentResponseForm, ObservationInternalForm, ObservationNoteForm, ObservationPublicForm, ObservationSendToDepartmentForm, ObservationStatusForm, ObservationTrackForm, ObservationTriageForm, ) from .models import ( Observation, ObservationAttachment, ObservationCategory, ObservationNote, ObservationStatus, ObservationStatusLog, VALID_OBSERVATION_TRANSITIONS, ) from .services import ObservationService logger = logging.getLogger(__name__) def _format_duration(start, end): """Format duration between two datetimes as a human-readable string.""" if not start or not end: return None duration = end - start total_seconds = int(duration.total_seconds()) if total_seconds < 60: return "1m" days = total_seconds // 86400 hours = (total_seconds % 86400) // 3600 minutes = (total_seconds % 3600) // 60 parts = [] if days > 0: parts.append(f"{days}d") if hours > 0: parts.append(f"{hours}h") if minutes > 0 and days == 0: parts.append(f"{minutes}m") return " ".join(parts) if parts else "1m" def _build_observation_stage_timeline(observation): """Build stage timeline with timestamps and durations for an observation.""" from .models import ObservationStatusLog stages = [] status_logs = list( ObservationStatusLog.objects.filter( observation=observation, ).select_related("changed_by").order_by("created_at") ) def _find_log(to_status=None): for log in status_logs: if to_status and log.to_status != to_status: continue return log return None def _user_name(user): return user.get_full_name() if user else None if observation.created_at: performed_by = _user_name(observation.created_by) if hasattr(observation, 'created_by') and observation.created_by else str(_("Patient")) stages.append({"label": _("Created"), "timestamp": observation.created_at, "color": "bg-slate-400", "icon": "plus-circle", "performed_by": performed_by}) if observation.triaged_at: performed_by = _user_name(observation.triaged_by) if hasattr(observation, 'triaged_by') else None stages.append({"label": _("Triaged"), "timestamp": observation.triaged_at, "color": "bg-indigo-500", "icon": "filter", "performed_by": performed_by}) if observation.activated_at: activate_log = _find_log(to_status="in_progress") performed_by = _user_name(activate_log.changed_by) if activate_log else None stages.append({"label": _("Activated"), "timestamp": observation.activated_at, "color": "bg-blue-500", "icon": "play-circle", "performed_by": performed_by}) if observation.forwarded_to_dept_at: stages.append({"label": _("Forwarded to Department"), "timestamp": observation.forwarded_to_dept_at, "color": "bg-purple-500", "icon": "send"}) if observation.escalated_at: stages.append({"label": _("Escalated"), "timestamp": observation.escalated_at, "color": "bg-red-500", "icon": "alert-triangle"}) if observation.dept_response_escalated_at: stages.append({"label": _("Dept Response Escalated"), "timestamp": observation.dept_response_escalated_at, "color": "bg-rose-600", "icon": "shield-alert"}) if observation.department_responded_at: performed_by = _user_name(observation.department_responded_by) if hasattr(observation, 'department_responded_by') else None stages.append({"label": _("Department Responded"), "timestamp": observation.department_responded_at, "color": "bg-amber-500", "icon": "message-square", "performed_by": performed_by}) if observation.resolved_at: performed_by = _user_name(observation.resolved_by) if hasattr(observation, 'resolved_by') else None if not performed_by: resolved_log = _find_log(to_status="resolved") performed_by = _user_name(resolved_log.changed_by) if resolved_log else None stages.append({"label": _("Resolved"), "timestamp": observation.resolved_at, "color": "bg-green-500", "icon": "check-circle", "performed_by": performed_by}) if observation.closed_at: performed_by = _user_name(observation.closed_by) if hasattr(observation, 'closed_by') else None if not performed_by: closed_log = _find_log(to_status="closed") performed_by = _user_name(closed_log.changed_by) if closed_log else None stages.append({"label": _("Closed"), "timestamp": observation.closed_at, "color": "bg-emerald-600", "icon": "circle-check", "performed_by": performed_by}) stages.sort(key=lambda s: s["timestamp"]) for i, stage in enumerate(stages): stage["duration_from_prev"] = _format_duration(stages[i - 1]["timestamp"], stage["timestamp"]) if i > 0 else None total_time = None if len(stages) >= 2: total_time = _format_duration(stages[0]["timestamp"], stages[-1]["timestamp"]) return {"stages": stages, "total_time": total_time} # ============================================================================= # PUBLIC VIEWS (No Login Required) # ============================================================================= def observation_create_public(request): """ Public view for submitting observations. No login required - anonymous submissions allowed. """ if request.method == "POST": form = ObservationPublicForm(request.POST, request.FILES) if form.is_valid(): try: client_ip = get_client_ip(request) user_agent = request.META.get("HTTP_USER_AGENT", "") attachments = request.FILES.getlist("attachments") hospital_id = request.POST.get("hospital") hospital = None if hospital_id: try: from apps.organizations.models import Hospital hospital = Hospital.objects.get(id=hospital_id) except Exception: pass observation = ObservationService.create_observation( description=form.cleaned_data["description"], severity=form.cleaned_data.get("severity", "medium"), category=None, title=form.cleaned_data.get("title", ""), location_text=form.cleaned_data.get("location_text", ""), location_type=form.cleaned_data.get("location_type", ""), incident_datetime=form.cleaned_data.get("incident_datetime"), reporter_staff_id=form.cleaned_data.get("reporter_staff_id", ""), reporter_name=form.cleaned_data.get("reporter_name", ""), reporter_phone=form.cleaned_data.get("reporter_phone", ""), reporter_email=form.cleaned_data.get("reporter_email", ""), national_id=form.cleaned_data.get("national_id", ""), client_ip=client_ip, user_agent=user_agent, attachments=attachments, hospital=hospital, area=form.cleaned_data.get("area"), section=form.cleaned_data.get("section"), source_legacy="public_form", ) return redirect("observations:observation_submitted", tracking_code=observation.tracking_code) except Exception as e: logger.error(f"Error creating observation: {e}") messages.error(request, "An error occurred while submitting your observation. Please try again.") else: form = ObservationPublicForm() from apps.organizations.models import Hospital context = { "form": form, "hospitals": Hospital.objects.filter(status="active").order_by("name"), } return render(request, "observations/public_new.html", context) def observation_submitted(request, tracking_code): """ Success page after observation submission. Shows tracking code for future reference. """ observation = get_object_or_404(Observation, tracking_code=tracking_code) context = { "observation": observation, "tracking_code": tracking_code, } return render(request, "observations/public_success.html", context) def observation_track(request): """ Public view to track observation status by tracking code. Shows minimal status information only (no internal notes). """ observation = None error_message = None tracking_code = request.GET.get("tracking_code", "").strip() if request.method == "POST": tracking_code = request.POST.get("tracking_code", "").strip() if not tracking_code: error_message = _("Please enter a tracking code.") else: try: observation = ( Observation.objects.select_related("category", "hospital") .prefetch_related("status_logs", "notes") .get(tracking_code__iexact=tracking_code) ) except Observation.DoesNotExist: error_message = _("No observation found with this tracking code. Please check and try again.") elif tracking_code: try: observation = ( Observation.objects.select_related("category", "hospital") .prefetch_related("status_logs", "notes") .get(tracking_code__iexact=tracking_code) ) except Observation.DoesNotExist: error_message = _("No observation found with this tracking code. Please check and try again.") has_response = False response_en = "" response_ar = "" if observation: has_response = bool(observation.department_response_en or observation.department_response_ar) response_en = observation.department_response_en or "" response_ar = observation.department_response_ar or "" context = { "observation": observation, "error_message": error_message, "tracking_code": tracking_code, "has_response": has_response, "response_en": response_en, "response_ar": response_ar, } return render(request, "observations/public_track.html", context) # ============================================================================= # INTERNAL VIEWS (Login Required) # ============================================================================= @login_required @require_http_methods(["GET", "POST"]) def observation_create(request): """ Internal view for authenticated staff to create observations. """ communication_request = None if request.method == "POST": form = ObservationInternalForm(request.POST, request=request) if form.is_valid(): try: client_ip = get_client_ip(request) user_agent = request.META.get("HTTP_USER_AGENT", "") user = request.user observation = ObservationService.create_observation( description=form.cleaned_data["description"], severity=form.cleaned_data.get("severity") or request.POST.get("severity", "medium"), category=None, title=form.cleaned_data.get("title", "") or "", location_text=form.cleaned_data.get("location_text", "") or "", incident_datetime=form.cleaned_data.get("incident_datetime"), reporter_staff_id=user.employee_id or "", reporter_name=user.get_full_name(), reporter_phone="", reporter_email=user.email, client_ip=client_ip, user_agent=user_agent, hospital=form.cleaned_data.get("hospital") or (user.hospital if hasattr(user, 'hospital') else None), assigned_department=form.cleaned_data.get("assigned_department"), source=form.cleaned_data.get("px_source"), source_legacy="staff_portal", ) assigned_dept = form.cleaned_data.get("assigned_department") section = form.cleaned_data.get("section") if assigned_dept: observation.assigned_department = assigned_dept if section: observation.section = section observation.patient_name = form.cleaned_data.get("patient_name", "") or "" observation.patient_national_id = form.cleaned_data.get("patient_national_id", "") or "" observation.patient_phone = form.cleaned_data.get("patient_phone", "") or "" observation.patient_email = form.cleaned_data.get("patient_email", "") or "" observation.assigned_to = user observation.status = ObservationStatus.IN_PROGRESS observation.save() ObservationStatusLog.objects.create( observation=observation, from_status=ObservationStatus.OPEN, to_status=ObservationStatus.IN_PROGRESS, comment="Auto-assigned during creation", ) comm_req_id = request.POST.get("comm_req") if comm_req_id: try: from apps.px_sources.models import CommunicationRequest cr = CommunicationRequest.objects.get(pk=comm_req_id) cr.link_to_record(observation) except Exception: pass StaffActivityService.log_from_request( request, activity_type="create", description=f"Created observation {observation.tracking_code}", content_object=observation, module="observations", ) messages.success(request, f"Observation {observation.tracking_code} created successfully.") return redirect("observations:observation_detail", pk=observation.id) except Exception as e: logger.error(f"Error creating observation: {e}") messages.error(request, "An error occurred while creating the observation. Please try again.") else: communication_request = None comm_req_id = request.GET.get("comm_req") initial_data = {} if comm_req_id: try: from apps.px_sources.models import CommunicationRequest cr_data = CommunicationRequest.get_initial_data(comm_req_id) communication_request = cr_data["communication_request"] for key in ("patient_file_number", "description"): if key in cr_data["initial"] and cr_data["initial"][key]: initial_data[key] = cr_data["initial"][key] except Exception: pass form = ObservationInternalForm(request=request, initial=initial_data) context = { "form": form, "communication_request": communication_request, } return render(request, "observations/observation_create.html", context) @login_required def observation_list(request): """ Internal view for listing observations with filters. Features: - Server-side pagination - Advanced filters (status, severity, category, department, etc.) - Search by tracking code, description - RBAC filtering """ # Base queryset with optimizations queryset = Observation.objects.select_related( "category", "assigned_department", "assigned_to", "triaged_by", "resolved_by", "closed_by" ) # Apply RBAC filters user = request.user # Get selected hospital for PX Admins (from middleware) selected_hospital = getattr(request, "tenant_hospital", None) if user.is_px_admin(): # PX Admins see all, but filter by selected hospital if set if selected_hospital: queryset = queryset.filter( Q(assigned_department__hospital=selected_hospital, hospital=selected_hospital) | Q(assigned_department__isnull=True, hospital=selected_hospital) ) elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter( Q(assigned_department__hospital=user.hospital, hospital=user.hospital) | Q(assigned_department__isnull=True, hospital=user.hospital) ) elif user.is_department_manager() and user.department: queryset = queryset.filter(assigned_department=user.department) elif user.hospital: queryset = queryset.filter(Q(assigned_department__hospital=user.hospital) | Q(assigned_department__isnull=True)) # Apply filters from request status_filter = request.GET.get("status") if status_filter: queryset = queryset.filter(status=status_filter) severity_filter = request.GET.get("severity") if severity_filter: queryset = queryset.filter(severity=severity_filter) category_filter = request.GET.get("category") if category_filter: queryset = queryset.filter(category_id=category_filter) department_filter = request.GET.get("assigned_department") if department_filter: queryset = queryset.filter(assigned_department_id=department_filter) assigned_to_filter = request.GET.get("assigned_to") if assigned_to_filter: queryset = queryset.filter(assigned_to_id=assigned_to_filter) is_anonymous_filter = request.GET.get("is_anonymous") if is_anonymous_filter == "yes": queryset = queryset.filter(reporter_staff_id="", reporter_name="") elif is_anonymous_filter == "no": queryset = queryset.exclude(reporter_staff_id="", reporter_name="") # Search search_query = request.GET.get("search") if search_query: queryset = queryset.filter( Q(tracking_code__icontains=search_query) | Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(reporter_name__icontains=search_query) | Q(reporter_staff_id__icontains=search_query) | Q(location_text__icontains=search_query) ) # Date range filters date_from = request.GET.get("date_from") if date_from: queryset = queryset.filter(created_at__date__gte=date_from) date_to = request.GET.get("date_to") if date_to: queryset = queryset.filter(created_at__date__lte=date_to) ALLOWED_ORDER_BY = {"-created_at", "created_at", "-updated_at", "updated_at", "severity", "-severity"} order_by = request.GET.get("order_by", "-created_at") if order_by not in ALLOWED_ORDER_BY: order_by = "-created_at" queryset = queryset.order_by(order_by) page_size = min(int(request.GET.get("page_size", 25)), 100) paginator = Paginator(queryset, page_size) page_number = request.GET.get("page", 1) page_obj = paginator.get_page(page_number) # Get filter options departments = Department.objects.filter(status="active") if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) from apps.core.utils import get_assignable_users assignable_users = get_assignable_users(user.hospital) categories = ObservationCategory.objects.filter(is_active=True) stats = ObservationService.get_statistics( hospital=selected_hospital or (user.hospital if not user.is_px_admin() else None), department=user.department if user.is_department_manager() else None, ) context = { "page_obj": page_obj, "observations": page_obj.object_list, "stats": stats, "departments": departments, "assignable_users": assignable_users, "categories": categories, "status_choices": ObservationStatus.choices, "filters": request.GET, } return render(request, "observations/observation_list.html", context) @login_required def observation_detail(request, pk): """ Internal view for observation details. Features: - Full observation details - Timeline (status logs + notes) - Attachments - Linked PX Action - Workflow actions """ observation = get_object_or_404( Observation.objects.select_related( "category", "assigned_department", "assigned_to", "triaged_by", "resolved_by", "closed_by", "legacy_location", "legacy_main_section", "legacy_subsection", "hospital", "taxonomy_domain", "taxonomy_category", "taxonomy_subcategory", "taxonomy_classification", ).prefetch_related("attachments", "observation_notes__created_by", "status_logs__changed_by"), pk=pk, ) # Check access user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and observation.assigned_department: if observation.assigned_department.hospital != user.hospital: messages.error(request, "You don't have permission to view this observation.") return redirect("observations:observation_list") elif user.is_department_manager() and observation.assigned_department: if observation.assigned_department != user.department: messages.error(request, "You don't have permission to view this observation.") return redirect("observations:observation_list") # Fallback: if no assigned_department, check by hospital if not observation.assigned_department and observation.hospital_id: if not user.is_px_admin() and user.hospital_id and observation.hospital_id != user.hospital_id: messages.error(request, "You don't have permission to view this observation.") return redirect("observations:observation_list") # Get timeline (combine status logs and notes) status_logs = list(observation.status_logs.all()) notes = list(observation.observation_notes.all()) timeline = [] for log in status_logs: timeline.append( { "type": "status_change", "created_at": log.created_at, "item": log, } ) for note in notes: timeline.append( { "type": "note", "created_at": note.created_at, "item": note, } ) timeline.sort(key=lambda x: x["created_at"], reverse=True) stage_timeline = _build_observation_stage_timeline(observation) # Get attachments attachments = observation.attachments.all() # Get linked PX Action if exists px_action = None if observation.action_id: from apps.px_action_center.models import PXAction try: px_action = PXAction.objects.get(id=observation.action_id) except PXAction.DoesNotExist: pass # Get assignable users and departments. # Only offer departments that can actually receive a send (have a champion or an # emailable manager) — matches the send endpoint's get_champion_and_manager() check. from apps.organizations.department_contacts import has_contact_target from apps.core.utils import get_assignable_users assignable_users = get_assignable_users(user.hospital) departments = ( Department.objects.filter(status="active") .select_related("champion", "manager", "manager__staff_profile") ) if user.hospital: departments = departments.filter(hospital=user.hospital) departments = [d for d in departments if has_contact_target(d)] # 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() or user.is_px_employee(), "can_convert": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), "can_send_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or user.is_department_manager() or user.is_px_management(), "can_respond_to_department": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or (user.is_champion() and observation.assigned_department == user.department), "can_send_reminder": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), "can_delete": user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee(), "can_admin": user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee(), "linked_rcas": linked_rcas, } from django.contrib.contenttypes.models import ContentType obs_ct = ContentType.objects.get_for_model(observation) context["content_type_id"] = obs_ct.pk context["object_id"] = observation.pk generic_notes = observation.notes.select_related("created_by").all() context["generic_notes"] = generic_notes context["generic_notes_count"] = generic_notes.count() escalation_targets = [] escalation_email_subject = "" escalation_email_body = "" if observation.assigned_department: from apps.organizations.models import Staff as OrgStaff dept = observation.assigned_department dept_holders = [ h for h in dept.get_role_holders() if h["staff"].user and h["staff"].user.is_active ] escalation_targets.extend(dept_holders) hospital_admins = OrgStaff.objects.filter( user__is_active=True, user__groups__name__in=["Hospital Admin", "Department Manager"], ) if observation.hospital: hospital_admins = hospital_admins.filter(department__hospital=observation.hospital) for admin in hospital_admins.select_related("user"): escalation_targets.append({ "staff": admin, "staff_id": str(admin.id), "name": admin.get_full_name(), "email": admin.email or (admin.user.email if admin.user else None), "role_field": "hospital_admin", "role_label": "Hospital Admin" if admin.user.groups.filter(name="Hospital Admin").exists() else "Department Manager", }) dept_name = dept.get_localized_name() escalation_email_subject = f"Observation Escalation - {observation.tracking_code} - {dept_name}" escalation_email_body = ( f"Dear Team,\n\n" f"This is to escalate observation {observation.tracking_code} regarding the {dept_name} department.\n\n" f"Severity: {observation.get_severity_display()}\n" f"Status: {observation.get_status_display()}\n" f"Description: {observation.description[:500]}\n\n" f"Please review and take appropriate action.\n\n" f"Thank you." ) context["escalation_targets"] = escalation_targets context["escalation_email_subject"] = escalation_email_subject context["escalation_email_body"] = escalation_email_body # Workflow stepper _obs_steps = [ {"label": "Created", "icon": "plus-circle", "done": True}, {"label": "Assigned", "icon": "user-plus", "done": observation.assigned_at is not None}, {"label": "Sent to Dept", "icon": "send", "done": observation.forwarded_to_dept_at is not None}, {"label": "Dept Response", "icon": "message-square", "done": bool(observation.department_response_en or observation.department_response_ar)}, {"label": "Resolved", "icon": "check-circle-2", "done": observation.status in ("resolved", "closed")}, ] _obs_next = next((i for i, s in enumerate(_obs_steps) if not s["done"]), len(_obs_steps)) if _obs_next < len(_obs_steps): _obs_steps[_obs_next]["current"] = True context["workflow_steps"] = _obs_steps 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() or user.is_px_employee()): messages.error(request, "You don't have permission to triage observations.") return redirect("observations:observation_detail", pk=pk) form = ObservationTriageForm(request.POST, hospital=user.hospital) if form.is_valid(): try: ObservationService.triage_observation( observation=observation, triaged_by=user, assigned_department=form.cleaned_data.get("assigned_department"), assigned_to=form.cleaned_data.get("assigned_to"), new_status=form.cleaned_data.get("status"), note=form.cleaned_data.get("note", ""), ) messages.success(request, "Observation triaged successfully.") except Exception as e: logger.error(f"Error triaging observation: {e}") messages.error(request, f"Error triaging observation: {str(e)}") else: messages.error(request, "Invalid form data.") return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_change_status(request, pk): """ Change observation status. """ observation = get_object_or_404(Observation, pk=pk) # Check permission user = request.user if not (user.has_perm("observations.triage_observation") or user.is_px_admin() or user.is_hospital_admin() or user.is_px_employee()): messages.error(request, "You don't have permission to change observation status.") return redirect("observations:observation_detail", pk=pk) form = ObservationStatusForm(request.POST) if form.is_valid(): try: ObservationService.change_status( observation=observation, new_status=form.cleaned_data["status"], changed_by=user, comment=form.cleaned_data.get("comment", ""), ) messages.success(request, f"Status changed to {form.cleaned_data['status']}.") except Exception as e: logger.error(f"Error changing status: {e}") messages.error(request, f"Error changing status: {str(e)}") else: messages.error(request, "Invalid form data.") return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_assign(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, "You don't have permission to assign observations.") return redirect("observations:observation_detail", pk=pk) user_id = request.POST.get("user_id") if not user_id: messages.error(request, "Please select a user to assign.") return redirect("observations:observation_detail", pk=pk) try: assignee = User.objects.get(id=user_id) old_assignee = observation.assigned_to old_status = observation.status observation.assigned_to = assignee observation.assigned_at = timezone.now() reopened = False new_status = old_status if old_status in ("resolved", "closed"): new_status = ObservationStatus.IN_PROGRESS observation.resolved_at = None observation.resolved_by = None observation.closed_at = None observation.closed_by = None reopened = True elif old_status == ObservationStatus.OPEN: new_status = ObservationStatus.IN_PROGRESS observation.save() ObservationService.change_status( observation=observation, new_status=new_status, changed_by=request.user, comment=f"{'Reopened and a' if reopened else 'A'}ssigned to {assignee.get_full_name()}" + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), ) messages.success(request, f"Observation {'reopened and ' if reopened else ''}assigned to {assignee.get_full_name()}.") except User.DoesNotExist: messages.error(request, "User not found.") return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_activate(request, pk): """Activate observation and assign to current user.""" observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to activate observations.")) return redirect("observations:observation_detail", pk=pk) if observation.assigned_to == user: messages.info(request, _("This observation is already assigned to you.")) return redirect("observations:observation_detail", pk=pk) old_assignee = observation.assigned_to old_status = observation.status observation.assigned_to = user observation.assigned_at = timezone.now() observation.save(update_fields=["assigned_to", "assigned_at"]) if old_status == ObservationStatus.OPEN: ObservationService.change_status( observation=observation, new_status=ObservationStatus.IN_PROGRESS, changed_by=user, comment=f"Observation activated and assigned to {user.get_full_name()}" + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), ) else: ObservationNote.objects.create( observation=observation, note=f"Observation reassigned to {user.get_full_name()}" + (f" (reassigned from {old_assignee.get_full_name()})" if old_assignee else ""), created_by=user, is_internal=True, ) StaffActivityService.log_from_request( request, activity_type="update", description=f"Activated observation {observation.tracking_code or observation.id}", content_object=observation, module="observations", ) messages.success(request, _("Observation activated and assigned to you successfully.")) return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_reopen(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, "You don't have permission to reopen observations.") return redirect("observations:observation_detail", pk=pk) if observation.status not in ("resolved", "closed"): messages.error(request, "Only resolved or closed observations can be reopened.") return redirect("observations:observation_detail", pk=pk) old_status = observation.status note_text = request.POST.get("note", "Observation reopened") ObservationService.change_status( observation=observation, new_status=ObservationStatus.IN_PROGRESS, changed_by=user, comment=note_text, ) observation.resolved_at = None observation.resolved_by = None observation.closed_at = None observation.closed_by = None observation.save(update_fields=["resolved_at", "resolved_by", "closed_at", "closed_by"]) messages.success(request, "Observation reopened successfully.") return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_add_note(request, pk): """ Add internal note to observation. """ observation = get_object_or_404(Observation, pk=pk) form = ObservationNoteForm(request.POST) if form.is_valid(): try: ObservationService.add_note( observation=observation, note=form.cleaned_data["note"], created_by=request.user, is_internal=form.cleaned_data.get("is_internal", True), ) messages.success(request, "Note added successfully.") except Exception as e: logger.error(f"Error adding note: {e}") messages.error(request, f"Error adding note: {str(e)}") else: messages.error(request, "Please enter a note.") return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_respond(request, pk): """Respond to observation with patient-facing response text.""" observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or observation.assigned_to == user ): messages.error(request, _("You don't have permission to respond to observations.")) return redirect("observations:observation_detail", pk=pk) response = request.POST.get("response", "").strip() if not response: messages.error(request, "Please enter a response.") return redirect("observations:observation_detail", pk=pk) observation.response = response observation.response_en = "" observation.response_ar = "" observation.responded_at = timezone.now() observation.responded_by = request.user observation.response_sent_at = timezone.now() if observation.status not in ("resolved", "closed"): observation.status = "resolved" observation.resolved_at = timezone.now() observation.resolved_by = request.user observation.save() from apps.core.services import AuditService AuditService.log_event( event_type="observation_responded", description=f"Response sent for observation {observation.tracking_code or observation.id}", user=request.user, content_object=observation, ) messages.success(request, _("Response sent successfully.")) return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_generate_ai_response(request, pk): """Generate AI-powered response for an observation in both English and Arabic.""" from django.http import JsonResponse from apps.core.ai_service import AIService from apps.core.services import AuditService import json, logging logger = logging.getLogger(__name__) observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() or observation.assigned_to == user ): return JsonResponse({"error": "You don't have permission."}, status=403) try: ai_desc_en = "" ai_desc_ar = "" if observation.metadata and "ai_analysis" in observation.metadata: ai_desc_en = observation.metadata["ai_analysis"].get("short_description_en", "") ai_desc_ar = observation.metadata["ai_analysis"].get("short_description_ar", "") dept_section = "" if observation.department_response_en or observation.department_response_ar: dept_en = observation.department_response_en or "" dept_ar = observation.department_response_ar or "" dept_section = f""" DEPARTMENT RESPONSE (use this as the primary basis): - English: {dept_en} - Arabic: {dept_ar} Transform the department response into a clear, patient-friendly response.""" prompt = f"""As a healthcare observation response specialist, generate a professional response to this observation in BOTH English and Arabic. OBSERVATION DETAILS: - Description: {observation.description} - Category: {observation.category.name if observation.category else 'General'} - Hospital: {observation.hospital.name if observation.hospital else 'Unknown'} AI SUMMARY (for context): - English: {ai_desc_en} - Arabic: {ai_desc_ar} {dept_section} Generate a professional response that: 1. Acknowledges the observation and thanks the reporter 2. Addresses what was observed 3. Explains any actions taken or planned 4. Uses a professional, empathetic tone IMPORTANT: Provide the response in BOTH languages as JSON: {{ "response_en": "The response text in English (2-4 paragraphs)", "response_ar": "نص الرد بالعربية (2-4 فقرات)" }}""" system_prompt = """You are an expert healthcare observation response specialist fluent in both English and Arabic. Generate comprehensive, professional responses in both languages. Use Modern Standard Arabic (Fusha).""" ai_response = AIService.chat_completion( prompt=prompt, system_prompt=system_prompt, temperature=0.4, max_tokens=1500, response_format="json_object", ) response_data = json.loads(ai_response) response_en = response_data.get("response_en", "").strip() response_ar = response_data.get("response_ar", "").strip() AuditService.log_event( event_type="ai_observation_response_generated", description=f"AI response generated for observation {observation.tracking_code}", user=request.user, content_object=observation, ) return JsonResponse({"success": True, "response_en": response_en, "response_ar": response_ar}) except Exception as e: logger.error(f"AI observation response generation failed: {e}") return JsonResponse({"success": False, "error": f"Failed to generate response: {str(e)}"}, status=500) @login_required @require_http_methods(["GET", "POST"]) def observation_convert_to_action(request, pk): """ Convert observation to PX Action. """ observation = get_object_or_404(Observation, pk=pk) # Check permission user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, "You don't have permission to convert observations to actions.") return redirect("observations:observation_detail", pk=pk) # Check if already converted if observation.action_id: messages.warning(request, "This observation has already been converted to an action.") return redirect("observations:observation_detail", pk=pk) if request.method == "POST": form = ConvertToActionForm(request.POST) if form.is_valid(): try: action = ObservationService.convert_to_action( observation=observation, created_by=user, title=form.cleaned_data["title"], description=form.cleaned_data["description"], category=form.cleaned_data["category"], priority=form.cleaned_data["priority"], assigned_department=form.cleaned_data.get("assigned_department"), assigned_to=form.cleaned_data.get("assigned_to"), ) messages.success(request, f"Observation converted to action successfully.") return redirect("observations:observation_detail", pk=pk) except Exception as e: logger.error(f"Error converting to action: {e}") messages.error(request, f"Error converting to action: {str(e)}") else: # Pre-populate form initial = { "title": f"Observation {observation.tracking_code}", "description": observation.description, "priority": observation.severity, "assigned_department": observation.assigned_department, "assigned_to": observation.assigned_to, } if observation.title: initial["title"] += f" - {observation.title}" elif observation.category: initial["title"] += f" - {observation.category.name_en}" form = ConvertToActionForm(initial=initial) context = { "observation": observation, "form": form, } return render(request, "observations/convert_to_action.html", context) @login_required @require_http_methods(["POST"]) def observation_send_to_department(request, pk): """ Send an observation to a department for response. """ observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to send observations to departments.")) return redirect("observations:observation_detail", pk=pk) # Must be activated (in_progress) before it can be sent to a department if observation.status == ObservationStatus.OPEN: messages.error(request, _("Activate this observation before sending it to a department.")) return redirect("observations:observation_detail", pk=pk) department_id = request.POST.get("department_id") if not department_id: messages.error(request, _("Please select a department.")) return redirect("observations:observation_detail", pk=pk) from apps.organizations.models import Department try: department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: messages.error(request, _("Department not found.")) return redirect("observations:observation_detail", pk=pk) if not department.champion and not department.manager: messages.error(request, _(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned.")) return redirect("observations:observation_detail", pk=pk) contact_person_id = request.POST.get("contact_person_id") if not contact_person_id: messages.error(request, _("Please select a contact person.")) return redirect("observations:observation_detail", pk=pk) contact_info = department.is_valid_contact_person(contact_person_id) if not contact_info: messages.error(request, _("Selected person is not a role holder in this department.")) return redirect("observations:observation_detail", pk=pk) contact_person = contact_info["staff"] note_en = request.POST.get("note_en", "").strip() note_ar = request.POST.get("note_ar", "").strip() recipient_type = request.POST.get("recipient_type", "staff") note_parts = [] if note_en: note_parts.append(note_en) if note_ar: note_parts.append(note_ar) combined_note = "\n\n".join(note_parts) # Use assigned_department as the outgoing department (reusing existing field) observation.assigned_department = department observation.forwarded_to_dept_at = timezone.now() observation.sent_to_department = True observation.sent_to_department_at = timezone.now() sla_config = observation.get_sla_config() if sla_config and sla_config.dept_response_hours: from datetime import timedelta observation.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours) observation.dept_response_is_overdue = False observation.dept_response_reminder_sent_at = None observation.dept_response_second_reminder_sent_at = None observation.dept_response_escalated_at = None old_status = observation.status observation.contact_status = "contacted" observation.contact_status_at = timezone.now() observation.contact_status_by = user observation.save() # Create note ObservationNote.objects.create( observation=observation, note=combined_note or f"Sent to {department.get_localized_name()} for response", created_by=user, is_internal=True, ) try: import secrets from apps.notifications.settings_service import NotificationServiceWithSettings # Generate token for response link response_token = secrets.token_urlsafe(32) observation.response_token = response_token observation.response_token_sent_at = timezone.now() observation.save(update_fields=["response_token", "response_token_sent_at"]) # Build response link from django.contrib.sites.shortcuts import get_current_site current_site = get_current_site(request) domain = current_site.domain if current_site else request.get_host() response_link = f"https://{domain}/observations/{observation.pk}/respond/{response_token}/" NotificationServiceWithSettings.send_observation_department_assigned( department, observation, context_note_en=note_en, context_note_ar=note_ar, recipient_type=recipient_type, ) contact_email = contact_person.email or (contact_person.user.email if contact_person.user else None) if contact_email: from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( email=contact_email, subject=f"Observation {observation.tracking_code} - Response Required", message=f"An observation has been sent to your department ({department.get_localized_name()}) for response.\n\nSubmit your response here: {response_link}\n\nDeadline: {observation.dept_response_sla_due_at}", html_message=f"""
""", related_object=observation, ) except Exception as e: logger.warning(f"Failed to send department notification: {e}") if recipient_type == "department_email": messages.success( request, _("Observation sent to %(dept)s department email.") % {"dept": department.get_localized_name()}, ) else: messages.success( request, _("Observation sent to %(dept)s. Department champions have been notified.") % {"dept": department.get_localized_name()}, ) return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_escalate(request, pk): from apps.organizations.models import Staff from django.contrib import messages as django_messages observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, _("You don't have permission to escalate observations.")) return redirect("observations:observation_detail", pk=pk) if observation.status in ("closed", "cancelled", "rejected"): messages.error(request, _("Cannot escalate a closed, cancelled, or rejected observation.")) return redirect("observations:observation_detail", pk=pk) escalate_to_id = request.POST.get("escalate_to", "") reason = request.POST.get("reason", "") escalate_to_staff = None escalate_to_user = None if escalate_to_id: try: escalate_to_staff = Staff.objects.get(id=escalate_to_id, status="active") if escalate_to_staff.user and escalate_to_staff.user.is_active: escalate_to_user = escalate_to_staff.user except Staff.DoesNotExist: pass if not escalate_to_user: messages.error(request, _("Please select a valid person to escalate to.")) return redirect("observations:observation_detail", pk=pk) observation.escalated_at = timezone.now() observation.save(update_fields=["escalated_at"]) ObservationStatusLog.objects.create( observation=observation, from_status=observation.status, to_status=observation.status, changed_by=user, comment=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", ) ObservationNote.objects.create( observation=observation, note=f"Observation escalated to {escalate_to_staff.get_full_name()}. Reason: {reason or 'N/A'}", created_by=user, is_internal=True, ) email_subject = request.POST.get("email_subject", f"Observation Escalated - {observation.tracking_code}") email_body = request.POST.get("email_body", "") if escalate_to_user.email: try: from apps.notifications.services import NotificationService, get_email_header_html NotificationService.send_email( email=escalate_to_user.email, subject=email_subject, message=email_body, html_message=f""" """, related_object=observation, metadata={ "notification_type": "observation_escalated", "escalated_by": str(user.id), "reason": reason, }, ) except Exception as e: logger.error(f"Failed to send observation escalation email: {e}") messages.success(request, _(f"Observation escalated to {escalate_to_staff.get_full_name()}.")) return redirect("observations:observation_detail", pk=pk) @login_required @require_http_methods(["POST"]) def observation_send_to(request, pk): """ Unified AJAX endpoint to send observation to either a person or department. """ from django.http import JsonResponse from apps.notifications.services import NotificationService, get_email_header_html from apps.accounts.models import User observation = get_object_or_404(Observation, pk=pk) user = request.user # Check permission if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_department_manager() or user.is_px_management() or user.is_px_employee() ): return JsonResponse({ "success": False, "error": str(_("You don't have permission to send this observation.")), }, status=403) # Must be activated (in_progress) before it can be sent/assigned if observation.status == ObservationStatus.OPEN: return JsonResponse({ "success": False, "error": str(_("Activate this observation before sending it to a department.")), }, status=400) recipient_type = request.POST.get("recipient_type", "department") note = request.POST.get("note", "").strip() try: if recipient_type == "person": person_id = request.POST.get("person_id") if not person_id: return JsonResponse({ "success": False, "error": str(_("Please select a person.")), }, status=400) try: person = User.objects.get(id=person_id) except User.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("User not found.")), }, status=400) # Assign observation to person observation.assigned_to = person observation.assigned_at = timezone.now() observation.save(update_fields=["assigned_to", "assigned_at"]) # Send notification if person.email: NotificationService.send_email( email=person.email, subject=f"Observation Assigned - {observation.tracking_code}", message=f"You have been assigned to observation #{observation.tracking_code}.", html_message=f""" """, related_object=observation, ) message = f"Observation sent to {person.get_full_name()}." else: # department department_id = request.POST.get("department_id") if not department_id: return JsonResponse({ "success": False, "error": str(_("Please select a department.")), }, status=400) try: department = Department.objects.select_related("champion", "manager").get(pk=department_id, status="active") except Department.DoesNotExist: return JsonResponse({ "success": False, "error": str(_("Department not found.")), }, status=400) # Auto-target the department's champion and manager (no contact-person picker) from apps.organizations.department_contacts import get_champion_and_manager from apps.notifications.services import NotificationService targets = get_champion_and_manager(department) if not targets: return JsonResponse({ "success": False, "error": str(_(f"Cannot send to {department.get_localized_name()}. This department has no champion or manager assigned. Please assign one before sending.")), }, status=400) # Send to department observation.assigned_department = department observation.forwarded_to_dept_at = timezone.now() observation.sent_to_department = True observation.sent_to_department_at = timezone.now() sla_config = observation.get_sla_config() if sla_config and sla_config.dept_response_hours: from datetime import timedelta observation.dept_response_sla_due_at = timezone.now() + timedelta(hours=sla_config.dept_response_hours) observation.dept_response_is_overdue = False observation.dept_response_reminder_sent_at = None observation.dept_response_second_reminder_sent_at = None observation.dept_response_escalated_at = None # Notify champion + manager (email + SMS) with the observation link notified = [] link = f"https://{request.get_host()}/observations/{observation.pk}/" for target in targets: display_name = ( target["staff"].get_full_name() if target.get("staff") else (target["user"].get_full_name() if target.get("user") else target["label"]) ) if target.get("email"): try: NotificationService.send_email( email=target["email"], subject=f"Observation Sent to Department - {observation.tracking_code}", message=f"Observation #{observation.tracking_code} has been sent to your department ({department.name}).\n\n{link}", related_object=observation, ) except Exception: pass if target.get("phone"): try: NotificationService.send_sms( target["phone"], f"PX360: Observation #{observation.tracking_code} sent to {department.name}. Review: {link}", related_object=observation, ) except Exception: pass notified.append(f"{display_name} ({target['label']})") message = f"Observation sent to {department.get_localized_name()} — {', '.join(notified)}." old_status_before_send = observation.status observation.contact_status = "contacted" observation.contact_status_at = timezone.now() observation.contact_status_by = user observation.save() ObservationNote.objects.create( observation=observation, note=note or f"Sent to {recipient_type}", created_by=user, is_internal=True, ) 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_px_management() or user.is_px_employee() 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.save() # Generate AI summary try: from apps.core.ai_service import AIService import json prompt = f"""You are an AI assistant for a hospital patient experience system. Summarize the following department response to a staff observation in 2-3 concise sentences. Observation: {observation.description[:500]} Department response: {response[:500]} Generate a JSON response with: - "summary_en": A brief summary of the department's response in English (2-3 sentences) - "summary_ar": The same summary translated to Modern Standard Arabic""" result = AIService.chat_completion( prompt=prompt, response_format="json_object", ) parsed = json.loads(result) observation.department_response_summary_en = parsed.get("summary_en", "") observation.department_response_summary_ar = parsed.get("summary_ar", "") observation.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) except Exception as e: logger.warning(f"AI summary of department response failed: {e}") ObservationStatusLog.objects.create( observation=observation, from_status=observation.status, to_status=observation.status, changed_by=user, comment=f"Department response submitted by {user.get_full_name()}", ) # Create note ObservationNote.objects.create( observation=observation, note=f"Department response submitted by {user.get_full_name()}", created_by=user, is_internal=True, ) messages.success(request, "Department response submitted successfully.") # Prefer a user-supplied relative next URL (e.g. back to the department # observation detail page) over the default department landing page. # Block protocol-relative URLs (``//host/`` or ``/\\host/``) to prevent # open-redirect abuse. next_url = request.POST.get("next") if not ( next_url and next_url.startswith("/") and not next_url.startswith("//") and not next_url.startswith("/\\") ): next_url = None if request.headers.get('X-Requested-With') == 'XMLHttpRequest': from django.http import JsonResponse from django.urls import reverse if next_url: redirect_url = next_url elif user.department: redirect_url = reverse("organizations:department_detail", kwargs={"pk": user.department.pk}) else: redirect_url = reverse("observations:observation_detail", kwargs={"pk": pk}) return JsonResponse({"success": True, "redirect_url": redirect_url}) if next_url: return redirect(next_url) if user.department: return redirect("organizations:department_detail", pk=user.department.pk) return redirect("observations:observation_detail", pk=pk) context = { "observation": observation, } return render(request, "observations/observation_department_response.html", context) @login_required @require_http_methods(["POST"]) def observation_send_dept_response_reminder(request, pk): observation = get_object_or_404(Observation, pk=pk) user = request.user if not ( user.is_px_admin() or user.is_hospital_admin() or user.is_px_management() or user.is_px_employee() ): messages.error(request, "You don't have permission to send reminders.") return redirect("observations:observation_detail", pk=pk) if observation.department_responded_at: messages.warning(request, "Department has already responded.") return redirect("observations:observation_detail", pk=pk) dept = observation.assigned_department if not dept: messages.error(request, "No department assigned.") return redirect("observations:observation_detail", pk=pk) reminder_type = request.POST.get("reminder_type", "first") if reminder_type == "first" and observation.dept_response_reminder_sent_at: messages.warning(request, "First reminder already sent.") return redirect("observations:observation_detail", pk=pk) if reminder_type == "second" and observation.dept_response_second_reminder_sent_at: messages.warning(request, "Second reminder already sent.") return redirect("observations:observation_detail", pk=pk) if reminder_type == "second" and not observation.dept_response_reminder_sent_at: messages.warning(request, "Please send the first reminder before the second.") return redirect("observations:observation_detail", pk=pk) try: from apps.notifications.services import NotificationService, get_email_header_html from apps.organizations.department_contacts import get_champion_and_manager targets = get_champion_and_manager(dept) if not targets: messages.error(request, f"No champion or manager assigned to {dept.name}.") return redirect("observations:observation_detail", pk=pk) recipients = [] for target in targets: email = target.get("email") or "" if email: recipients.append({"email": email, "name": target.get("display_name", "")}) if reminder_type == "first": observation.dept_response_reminder_sent_at = timezone.now() else: observation.dept_response_second_reminder_sent_at = timezone.now() observation.save() from apps.observations.tasks import send_observation_reminder_email send_observation_reminder_email.delay(str(observation.id), reminder_type, recipients) 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 def category_list(request): """ List observation categories. """ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()): from django.core.exceptions import PermissionDenied raise PermissionDenied categories = ObservationCategory.objects.all().order_by("sort_order", "name_en") context = { "categories": categories, } return render(request, "observations/category_list.html", context) @login_required @require_http_methods(["GET", "POST"]) def category_create(request): """ Create new observation category. """ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()): from django.core.exceptions import PermissionDenied raise PermissionDenied 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 @require_http_methods(["GET", "POST"]) def category_edit(request, pk): """ Edit observation category. """ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()): from django.core.exceptions import PermissionDenied raise PermissionDenied 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 @require_http_methods(["POST"]) def category_delete(request, pk): """ Delete observation category. """ if not (request.user.has_perm("observations.manage_categories") or request.user.is_px_admin() or request.user.is_px_employee()): from django.core.exceptions import PermissionDenied raise PermissionDenied category = get_object_or_404(ObservationCategory, pk=pk) # Check if category is in use if category.observations.exists(): messages.error(request, "Cannot delete category that is in use.") return redirect("observations:category_list") category.delete() messages.success(request, "Category deleted successfully.") return redirect("observations:category_list") # ============================================================================= # AJAX/API HELPERS # ============================================================================= @login_required def get_users_by_department(request): """ Get users for a department (AJAX). """ department_id = request.GET.get("department_id") if not department_id: return JsonResponse({"users": []}) users = User.objects.filter(is_active=True, department_id=department_id).values( "id", "first_name", "last_name", "email" ) return JsonResponse( { "users": [ { "id": str(u["id"]), "name": f"{u['first_name']} {u['last_name']}", "email": u["email"], } for u in users ] } ) # ============================================================================= # UTILITY FUNCTIONS # ============================================================================= def get_client_ip(request): """ Get client IP address from request. """ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: ip = x_forwarded_for.split(",")[0].strip() else: ip = request.META.get("REMOTE_ADDR") return ip @login_required @require_http_methods(["POST"]) def observation_soft_delete(request, pk): observation = get_object_or_404(Observation, pk=pk) if not ( request.user.is_px_admin() or request.user.is_hospital_admin() or request.user.is_px_management() or request.user.is_px_employee() ): return HttpResponseForbidden(_("You don't have permission to delete observations.")) observation.soft_delete(user=request.user) messages.success(request, _("Observation moved to trash.")) return redirect("observations:observation_list") @login_required @require_http_methods(["POST"]) def observation_restore(request, pk): observation = get_object_or_404(Observation.all_objects, pk=pk, is_deleted=True) if not ( request.user.is_px_admin() or request.user.is_hospital_admin() or request.user.is_px_management() or request.user.is_px_employee() ): return HttpResponseForbidden(_("You don't have permission to restore observations.")) observation.restore() messages.success(request, _("Observation restored successfully.")) return redirect("observations:observation_list") def observation_respond_with_token(request, pk, token): """ Public-facing form for department staff to submit response to an observation. Does NOT require authentication. Validates token and checks validity. """ from apps.core.ai_service import AIService from apps.notifications.services import NotificationService from apps.core.utils import build_public_track_url observation = get_object_or_404(Observation, pk=pk) if observation.response_token != token or not observation.response_token: return render(request, "observations/response_token_invalid.html", {"observation": observation}) if observation.response_token_used: return render(request, "observations/response_already_submitted.html", {"observation": observation}) if request.method == "POST": response_en = request.POST.get("response_en", "").strip() response_ar = request.POST.get("response_ar", "").strip() response = response_en or response_ar if not response: return render(request, "observations/response_form_token.html", { "observation": observation, "error": "Please enter a response in at least one language.", }) observation.department_response_en = response_en observation.department_response_ar = response_ar observation.department_responded_at = timezone.now() observation.dept_response_is_overdue = False observation.response_token_used = True observation.save() # AI summary try: import json prompt = f"""Summarize the following department response to a staff observation in 2-3 concise sentences. Observation: {observation.description[:500]} Department response: {response[:500]} Generate JSON with "summary_en" and "summary_ar".""" result = AIService.chat_completion(prompt=prompt, response_format="json_object") parsed = json.loads(result) observation.department_response_summary_en = parsed.get("summary_en", "") observation.department_response_summary_ar = parsed.get("summary_ar", "") observation.save(update_fields=["department_response_summary_en", "department_response_summary_ar"]) except Exception: pass # Notify reporter track_url = build_public_track_url("observation", observation.tracking_code) if observation.reporter_phone: try: NotificationService.send_sms( phone=observation.reporter_phone, message=f"PX360: Your observation {observation.tracking_code} has been responded to. View: {track_url}", related_object=observation, ) except Exception: pass if observation.reporter_email: try: NotificationService.send_email( email=observation.reporter_email, subject=f"PX360: Response to Your Observation {observation.tracking_code}", message=f"Your observation has been responded to.\n\nView: {track_url}", related_object=observation, ) except Exception: pass return render(request, "observations/response_success_token.html", {"observation": observation}) return render(request, "observations/response_form_token.html", {"observation": observation}) @login_required def observation_pdf(request, pk): from apps.core.pdf_utils import generate_letterhead_pdf obj = get_object_or_404(Observation, id=pk) return generate_letterhead_pdf( 'observations/observation_pdf.html', {'object': obj}, f'observation_{obj.tracking_code}.pdf', obj=obj, )