673 lines
23 KiB
Python
673 lines
23 KiB
Python
"""
|
|
Observations views - Public and internal views for observation management.
|
|
|
|
Public views (no login required):
|
|
- observation_create_public: Submit new observation
|
|
- observation_submitted: Success page with tracking code
|
|
- observation_track: Track observation by code
|
|
|
|
Internal views (login required):
|
|
- observation_list: List all observations with filters
|
|
- observation_detail: View observation details
|
|
- observation_triage: Triage observation
|
|
- observation_change_status: Change observation status
|
|
- observation_add_note: Add internal note
|
|
- observation_convert_to_action: Convert to PX Action
|
|
- category_list: Manage categories
|
|
- category_create/edit/delete: Category CRUD
|
|
"""
|
|
import logging
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.decorators import login_required, permission_required
|
|
from django.core.paginator import Paginator
|
|
from django.db.models import Q
|
|
from django.http import JsonResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.utils import timezone
|
|
from django.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
|