""" Observations views - Public and internal views for observation management. Public views (no login required): - observation_create_public: Submit new observation - observation_submitted: Success page with tracking code - observation_track: Track observation by code Internal views (login required): - observation_list: List all observations with filters - observation_detail: View observation details - observation_triage: Triage observation - observation_change_status: Change observation status - observation_add_note: Add internal note - observation_convert_to_action: Convert to PX Action - category_list: Manage categories - category_create/edit/delete: Category CRUD """ import logging from django.contrib import messages from django.contrib.auth.decorators import login_required, permission_required from django.core.paginator import Paginator from django.db.models import Q from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_http_methods from apps.accounts.models import User from apps.organizations.models import Department from .forms import ( ConvertToActionForm, ObservationCategoryForm, ObservationNoteForm, ObservationPublicForm, ObservationStatusForm, ObservationTrackForm, ObservationTriageForm, ) from .models import ( Observation, ObservationAttachment, ObservationCategory, ObservationNote, ObservationStatus, ObservationStatusLog, ) from .services import ObservationService logger = logging.getLogger(__name__) # ============================================================================= # PUBLIC VIEWS (No Login Required) # ============================================================================= def observation_create_public(request): """ Public view for submitting observations. No login required - anonymous submissions allowed. """ if request.method == 'POST': form = ObservationPublicForm(request.POST, request.FILES) if form.is_valid(): try: # Get client info client_ip = get_client_ip(request) user_agent = request.META.get('HTTP_USER_AGENT', '') # Handle file uploads attachments = request.FILES.getlist('attachments') # Create observation using service observation = ObservationService.create_observation( description=form.cleaned_data['description'], severity=form.cleaned_data['severity'], category=form.cleaned_data.get('category'), title=form.cleaned_data.get('title', ''), location_text=form.cleaned_data.get('location_text', ''), incident_datetime=form.cleaned_data.get('incident_datetime'), reporter_staff_id=form.cleaned_data.get('reporter_staff_id', ''), reporter_name=form.cleaned_data.get('reporter_name', ''), reporter_phone=form.cleaned_data.get('reporter_phone', ''), reporter_email=form.cleaned_data.get('reporter_email', ''), client_ip=client_ip, user_agent=user_agent, attachments=attachments, ) return redirect('observations:observation_submitted', tracking_code=observation.tracking_code) except Exception as e: logger.error(f"Error creating observation: {e}") messages.error(request, "An error occurred while submitting your observation. Please try again.") else: form = ObservationPublicForm() context = { 'form': form, 'categories': ObservationCategory.objects.filter(is_active=True).order_by('sort_order'), } return render(request, 'observations/public_new.html', context) def observation_submitted(request, tracking_code): """ Success page after observation submission. Shows tracking code for future reference. """ observation = get_object_or_404(Observation, tracking_code=tracking_code) context = { 'observation': observation, 'tracking_code': tracking_code, } return render(request, 'observations/public_success.html', context) def observation_track(request): """ Public view to track observation status by tracking code. Shows minimal status information only (no internal notes). """ observation = None form = ObservationTrackForm(request.GET or None) if request.GET.get('tracking_code'): if form.is_valid(): tracking_code = form.cleaned_data['tracking_code'] try: observation = Observation.objects.get(tracking_code=tracking_code) except Observation.DoesNotExist: messages.error(request, "No observation found with that tracking code.") context = { 'form': form, 'observation': observation, } return render(request, 'observations/public_track.html', context) # ============================================================================= # INTERNAL VIEWS (Login Required) # ============================================================================= @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 if user.is_px_admin(): pass # See all elif user.is_hospital_admin() and user.hospital: queryset = queryset.filter( Q(assigned_department__hospital=user.hospital) | Q(assigned_department__isnull=True) ) elif user.is_department_manager() and user.department: queryset = queryset.filter(assigned_department=user.department) elif user.hospital: queryset = queryset.filter( Q(assigned_department__hospital=user.hospital) | Q(assigned_department__isnull=True) ) # Apply filters from request status_filter = request.GET.get('status') if status_filter: queryset = queryset.filter(status=status_filter) severity_filter = request.GET.get('severity') if severity_filter: queryset = queryset.filter(severity=severity_filter) category_filter = request.GET.get('category') if category_filter: queryset = queryset.filter(category_id=category_filter) department_filter = request.GET.get('assigned_department') if department_filter: queryset = queryset.filter(assigned_department_id=department_filter) assigned_to_filter = request.GET.get('assigned_to') if assigned_to_filter: queryset = queryset.filter(assigned_to_id=assigned_to_filter) is_anonymous_filter = request.GET.get('is_anonymous') if is_anonymous_filter == 'yes': queryset = queryset.filter(reporter_staff_id='', reporter_name='') elif is_anonymous_filter == 'no': queryset = queryset.exclude(reporter_staff_id='', reporter_name='') # Search search_query = request.GET.get('search') if search_query: queryset = queryset.filter( Q(tracking_code__icontains=search_query) | Q(title__icontains=search_query) | Q(description__icontains=search_query) | Q(reporter_name__icontains=search_query) | Q(reporter_staff_id__icontains=search_query) | Q(location_text__icontains=search_query) ) # Date range filters date_from = request.GET.get('date_from') if date_from: queryset = queryset.filter(created_at__date__gte=date_from) date_to = request.GET.get('date_to') if date_to: queryset = queryset.filter(created_at__date__lte=date_to) # Ordering order_by = request.GET.get('order_by', '-created_at') queryset = queryset.order_by(order_by) # Pagination page_size = int(request.GET.get('page_size', 25)) paginator = Paginator(queryset, page_size) page_number = request.GET.get('page', 1) page_obj = paginator.get_page(page_number) # Get filter options departments = Department.objects.filter(status='active') if not user.is_px_admin() and user.hospital: departments = departments.filter(hospital=user.hospital) assignable_users = User.objects.filter(is_active=True) if not user.is_px_admin() and user.hospital: assignable_users = assignable_users.filter(hospital=user.hospital) categories = ObservationCategory.objects.filter(is_active=True) # Statistics stats = ObservationService.get_statistics() context = { 'page_obj': page_obj, 'observations': page_obj.object_list, 'stats': stats, 'departments': departments, 'assignable_users': assignable_users, 'categories': categories, 'status_choices': ObservationStatus.choices, 'filters': request.GET, } return render(request, 'observations/observation_list.html', context) @login_required def observation_detail(request, pk): """ Internal view for observation details. Features: - Full observation details - Timeline (status logs + notes) - Attachments - Linked PX Action - Workflow actions """ observation = get_object_or_404( Observation.objects.select_related( 'category', 'assigned_department', 'assigned_to', 'triaged_by', 'resolved_by', 'closed_by' ).prefetch_related( 'attachments', 'notes__created_by', 'status_logs__changed_by' ), pk=pk ) # Check access user = request.user if not user.is_px_admin(): if user.is_hospital_admin() and observation.assigned_department: if observation.assigned_department.hospital != user.hospital: messages.error(request, "You don't have permission to view this observation.") return redirect('observations:observation_list') elif user.is_department_manager() and observation.assigned_department: if observation.assigned_department != user.department: messages.error(request, "You don't have permission to view this observation.") return redirect('observations:observation_list') # Get timeline (combine status logs and notes) status_logs = list(observation.status_logs.all()) notes = list(observation.notes.all()) timeline = [] for log in status_logs: timeline.append({ 'type': 'status_change', 'created_at': log.created_at, 'item': log, }) for note in notes: timeline.append({ 'type': 'note', 'created_at': note.created_at, 'item': note, }) timeline.sort(key=lambda x: x['created_at'], reverse=True) # Get attachments attachments = observation.attachments.all() # Get linked PX Action if exists px_action = None if observation.action_id: from apps.px_action_center.models import PXAction try: px_action = PXAction.objects.get(id=observation.action_id) except PXAction.DoesNotExist: pass # Get assignable users and departments departments = Department.objects.filter(status='active') assignable_users = User.objects.filter(is_active=True) if user.hospital: departments = departments.filter(hospital=user.hospital) assignable_users = assignable_users.filter(hospital=user.hospital) # Forms triage_form = ObservationTriageForm( initial={ 'assigned_department': observation.assigned_department, 'assigned_to': observation.assigned_to, 'status': observation.status, }, hospital=user.hospital ) status_form = ObservationStatusForm(initial={'status': observation.status}) note_form = ObservationNoteForm() context = { 'observation': observation, 'timeline': timeline, 'attachments': attachments, 'px_action': px_action, 'departments': departments, 'assignable_users': assignable_users, 'triage_form': triage_form, 'status_form': status_form, 'note_form': note_form, 'status_choices': ObservationStatus.choices, 'can_triage': user.has_perm('observations.triage_observation') or user.is_px_admin(), 'can_convert': user.is_px_admin() or user.is_hospital_admin(), } return render(request, 'observations/observation_detail.html', context) @login_required @require_http_methods(["POST"]) def observation_triage(request, pk): """ Triage an observation - assign department/owner and update status. """ observation = get_object_or_404(Observation, pk=pk) # Check permission user = request.user if not (user.has_perm('observations.triage_observation') or user.is_px_admin()): messages.error(request, "You don't have permission to triage observations.") return redirect('observations:observation_detail', pk=pk) form = ObservationTriageForm(request.POST, hospital=user.hospital) if form.is_valid(): try: ObservationService.triage_observation( observation=observation, triaged_by=user, assigned_department=form.cleaned_data.get('assigned_department'), assigned_to=form.cleaned_data.get('assigned_to'), new_status=form.cleaned_data.get('status'), note=form.cleaned_data.get('note', ''), ) messages.success(request, "Observation triaged successfully.") except Exception as e: logger.error(f"Error triaging observation: {e}") messages.error(request, f"Error triaging observation: {str(e)}") else: messages.error(request, "Invalid form data.") return redirect('observations:observation_detail', pk=pk) @login_required @require_http_methods(["POST"]) def observation_change_status(request, pk): """ Change observation status. """ observation = get_object_or_404(Observation, pk=pk) # Check permission user = request.user if not (user.has_perm('observations.triage_observation') or user.is_px_admin()): messages.error(request, "You don't have permission to change observation status.") return redirect('observations:observation_detail', pk=pk) form = ObservationStatusForm(request.POST) if form.is_valid(): try: ObservationService.change_status( observation=observation, new_status=form.cleaned_data['status'], changed_by=user, comment=form.cleaned_data.get('comment', ''), ) messages.success(request, f"Status changed to {form.cleaned_data['status']}.") except Exception as e: logger.error(f"Error changing status: {e}") messages.error(request, f"Error changing status: {str(e)}") else: messages.error(request, "Invalid form data.") return redirect('observations:observation_detail', pk=pk) @login_required @require_http_methods(["POST"]) def observation_add_note(request, pk): """ Add internal note to observation. """ observation = get_object_or_404(Observation, pk=pk) form = ObservationNoteForm(request.POST) if form.is_valid(): try: ObservationService.add_note( observation=observation, note=form.cleaned_data['note'], created_by=request.user, is_internal=form.cleaned_data.get('is_internal', True), ) messages.success(request, "Note added successfully.") except Exception as e: logger.error(f"Error adding note: {e}") messages.error(request, f"Error adding note: {str(e)}") else: messages.error(request, "Please enter a note.") return redirect('observations:observation_detail', pk=pk) @login_required @require_http_methods(["GET", "POST"]) def observation_convert_to_action(request, pk): """ Convert observation to PX Action. """ observation = get_object_or_404(Observation, pk=pk) # Check permission user = request.user if not (user.is_px_admin() or user.is_hospital_admin()): messages.error(request, "You don't have permission to convert observations to actions.") return redirect('observations:observation_detail', pk=pk) # Check if already converted if observation.action_id: messages.warning(request, "This observation has already been converted to an action.") return redirect('observations:observation_detail', pk=pk) if request.method == 'POST': form = ConvertToActionForm(request.POST) if form.is_valid(): try: action = ObservationService.convert_to_action( observation=observation, created_by=user, title=form.cleaned_data['title'], description=form.cleaned_data['description'], category=form.cleaned_data['category'], priority=form.cleaned_data['priority'], assigned_department=form.cleaned_data.get('assigned_department'), assigned_to=form.cleaned_data.get('assigned_to'), ) messages.success(request, f"Observation converted to action successfully.") return redirect('observations:observation_detail', pk=pk) except Exception as e: logger.error(f"Error converting to action: {e}") messages.error(request, f"Error converting to action: {str(e)}") else: # Pre-populate form initial = { 'title': f"Observation {observation.tracking_code}", 'description': observation.description, 'priority': observation.severity, 'assigned_department': observation.assigned_department, 'assigned_to': observation.assigned_to, } if observation.title: initial['title'] += f" - {observation.title}" elif observation.category: initial['title'] += f" - {observation.category.name_en}" form = ConvertToActionForm(initial=initial) context = { 'observation': observation, 'form': form, } return render(request, 'observations/convert_to_action.html', context) # ============================================================================= # CATEGORY MANAGEMENT VIEWS # ============================================================================= @login_required @permission_required('observations.manage_categories', raise_exception=True) def category_list(request): """ List observation categories. """ categories = ObservationCategory.objects.all().order_by('sort_order', 'name_en') context = { 'categories': categories, } return render(request, 'observations/category_list.html', context) @login_required @permission_required('observations.manage_categories', raise_exception=True) @require_http_methods(["GET", "POST"]) def category_create(request): """ Create new observation category. """ if request.method == 'POST': form = ObservationCategoryForm(request.POST) if form.is_valid(): form.save() messages.success(request, "Category created successfully.") return redirect('observations:category_list') else: form = ObservationCategoryForm() context = { 'form': form, 'title': 'Create Category', } return render(request, 'observations/category_form.html', context) @login_required @permission_required('observations.manage_categories', raise_exception=True) @require_http_methods(["GET", "POST"]) def category_edit(request, pk): """ Edit observation category. """ category = get_object_or_404(ObservationCategory, pk=pk) if request.method == 'POST': form = ObservationCategoryForm(request.POST, instance=category) if form.is_valid(): form.save() messages.success(request, "Category updated successfully.") return redirect('observations:category_list') else: form = ObservationCategoryForm(instance=category) context = { 'form': form, 'category': category, 'title': 'Edit Category', } return render(request, 'observations/category_form.html', context) @login_required @permission_required('observations.manage_categories', raise_exception=True) @require_http_methods(["POST"]) def category_delete(request, pk): """ Delete observation category. """ category = get_object_or_404(ObservationCategory, pk=pk) # Check if category is in use if category.observations.exists(): messages.error(request, "Cannot delete category that is in use.") return redirect('observations:category_list') category.delete() messages.success(request, "Category deleted successfully.") return redirect('observations:category_list') # ============================================================================= # AJAX/API HELPERS # ============================================================================= @login_required def get_users_by_department(request): """ Get users for a department (AJAX). """ department_id = request.GET.get('department_id') if not department_id: return JsonResponse({'users': []}) users = User.objects.filter( is_active=True, department_id=department_id ).values('id', 'first_name', 'last_name', 'email') return JsonResponse({ 'users': [ { 'id': str(u['id']), 'name': f"{u['first_name']} {u['last_name']}", 'email': u['email'], } for u in users ] }) # ============================================================================= # UTILITY FUNCTIONS # ============================================================================= def get_client_ip(request): """ Get client IP address from request. """ x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') if x_forwarded_for: ip = x_forwarded_for.split(',')[0].strip() else: ip = request.META.get('REMOTE_ADDR') return ip