414 lines
13 KiB
Python
414 lines
13 KiB
Python
"""
|
|
Observations forms - Public and internal forms for observation management.
|
|
"""
|
|
from django import forms
|
|
from django.core.validators import FileExtensionValidator
|
|
from django.utils import timezone
|
|
|
|
from apps.accounts.models import User
|
|
from apps.organizations.models import Department
|
|
|
|
from .models import (
|
|
Observation,
|
|
ObservationAttachment,
|
|
ObservationCategory,
|
|
ObservationNote,
|
|
ObservationSeverity,
|
|
ObservationStatus,
|
|
)
|
|
|
|
|
|
# Allowed file extensions for attachments
|
|
ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx']
|
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
|
|
|
|
|
class ObservationPublicForm(forms.ModelForm):
|
|
"""
|
|
Public form for submitting observations.
|
|
|
|
Features:
|
|
- No login required
|
|
- Optional reporter information
|
|
- Honeypot field for spam protection
|
|
- File attachments support
|
|
"""
|
|
# Honeypot field for spam protection
|
|
website = forms.CharField(
|
|
required=False,
|
|
widget=forms.TextInput(attrs={
|
|
'style': 'display:none !important;',
|
|
'tabindex': '-1',
|
|
'autocomplete': 'off',
|
|
}),
|
|
label=''
|
|
)
|
|
|
|
# File attachments (handled separately in the view)
|
|
# Note: Multiple file upload is handled via HTML attribute and view processing
|
|
attachments = forms.FileField(
|
|
required=False,
|
|
widget=forms.FileInput(attrs={
|
|
'accept': '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx',
|
|
'class': 'form-control',
|
|
}),
|
|
help_text="Upload a file (max 10MB). Allowed: images, PDF, Word, Excel."
|
|
)
|
|
|
|
class Meta:
|
|
model = Observation
|
|
fields = [
|
|
'category',
|
|
'title',
|
|
'description',
|
|
'severity',
|
|
'location_text',
|
|
'incident_datetime',
|
|
'reporter_staff_id',
|
|
'reporter_name',
|
|
'reporter_phone',
|
|
'reporter_email',
|
|
]
|
|
widgets = {
|
|
'category': forms.Select(attrs={
|
|
'class': 'form-select',
|
|
}),
|
|
'title': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Brief title (optional)',
|
|
}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 5,
|
|
'placeholder': 'Please describe what you observed in detail...',
|
|
}),
|
|
'severity': forms.Select(attrs={
|
|
'class': 'form-select',
|
|
}),
|
|
'location_text': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., Building A, Floor 2, Room 205',
|
|
}),
|
|
'incident_datetime': forms.DateTimeInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'datetime-local',
|
|
}),
|
|
'reporter_staff_id': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Your staff ID (optional)',
|
|
}),
|
|
'reporter_name': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Your name (optional)',
|
|
}),
|
|
'reporter_phone': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Your phone number (optional)',
|
|
}),
|
|
'reporter_email': forms.EmailInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Your email (optional)',
|
|
}),
|
|
}
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Only show active categories
|
|
self.fields['category'].queryset = ObservationCategory.objects.filter(
|
|
is_active=True
|
|
).order_by('sort_order', 'name_en')
|
|
self.fields['category'].empty_label = "Select a category (optional)"
|
|
|
|
# Set default incident datetime to now
|
|
if not self.instance.pk:
|
|
self.initial['incident_datetime'] = timezone.now().strftime('%Y-%m-%dT%H:%M')
|
|
|
|
def clean_website(self):
|
|
"""Honeypot validation - should be empty."""
|
|
website = self.cleaned_data.get('website')
|
|
if website:
|
|
raise forms.ValidationError("Spam detected.")
|
|
return website
|
|
|
|
def clean_description(self):
|
|
"""Validate description is not too short."""
|
|
description = self.cleaned_data.get('description', '')
|
|
if len(description.strip()) < 10:
|
|
raise forms.ValidationError(
|
|
"Please provide a more detailed description (at least 10 characters)."
|
|
)
|
|
return description
|
|
|
|
|
|
class ObservationTriageForm(forms.Form):
|
|
"""
|
|
Form for triaging observations.
|
|
|
|
Used by PX360 staff to assign department, owner, and update status.
|
|
"""
|
|
assigned_department = forms.ModelChoiceField(
|
|
queryset=Department.objects.filter(status='active'),
|
|
required=False,
|
|
empty_label="Select department",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
assigned_to = forms.ModelChoiceField(
|
|
queryset=User.objects.filter(is_active=True),
|
|
required=False,
|
|
empty_label="Select assignee",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
status = forms.ChoiceField(
|
|
choices=ObservationStatus.choices,
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
note = forms.CharField(
|
|
required=False,
|
|
widget=forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Add a note about this triage action (optional)...',
|
|
})
|
|
)
|
|
|
|
def __init__(self, *args, hospital=None, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Filter departments by hospital if provided
|
|
if hospital:
|
|
self.fields['assigned_department'].queryset = Department.objects.filter(
|
|
hospital=hospital,
|
|
status='active'
|
|
)
|
|
self.fields['assigned_to'].queryset = User.objects.filter(
|
|
is_active=True,
|
|
hospital=hospital
|
|
)
|
|
|
|
|
|
class ObservationStatusForm(forms.Form):
|
|
"""
|
|
Form for changing observation status.
|
|
"""
|
|
status = forms.ChoiceField(
|
|
choices=ObservationStatus.choices,
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
comment = forms.CharField(
|
|
required=False,
|
|
widget=forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Add a comment about this status change (optional)...',
|
|
})
|
|
)
|
|
|
|
|
|
class ObservationNoteForm(forms.ModelForm):
|
|
"""
|
|
Form for adding internal notes to observations.
|
|
"""
|
|
class Meta:
|
|
model = ObservationNote
|
|
fields = ['note', 'is_internal']
|
|
widgets = {
|
|
'note': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Add your note here...',
|
|
}),
|
|
'is_internal': forms.CheckboxInput(attrs={
|
|
'class': 'form-check-input',
|
|
}),
|
|
}
|
|
|
|
|
|
class ObservationCategoryForm(forms.ModelForm):
|
|
"""
|
|
Form for managing observation categories.
|
|
"""
|
|
class Meta:
|
|
model = ObservationCategory
|
|
fields = ['name_en', 'name_ar', 'description', 'icon', 'sort_order', 'is_active']
|
|
widgets = {
|
|
'name_en': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Category name in English',
|
|
}),
|
|
'name_ar': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Category name in Arabic',
|
|
'dir': 'rtl',
|
|
}),
|
|
'description': forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 3,
|
|
'placeholder': 'Optional description...',
|
|
}),
|
|
'icon': forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'e.g., bi-exclamation-triangle',
|
|
}),
|
|
'sort_order': forms.NumberInput(attrs={
|
|
'class': 'form-control',
|
|
'min': 0,
|
|
}),
|
|
'is_active': forms.CheckboxInput(attrs={
|
|
'class': 'form-check-input',
|
|
}),
|
|
}
|
|
|
|
|
|
class ObservationFilterForm(forms.Form):
|
|
"""
|
|
Form for filtering observations in the list view.
|
|
"""
|
|
search = forms.CharField(
|
|
required=False,
|
|
widget=forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
'placeholder': 'Search by tracking code, description...',
|
|
})
|
|
)
|
|
|
|
status = forms.ChoiceField(
|
|
required=False,
|
|
choices=[('', 'All Statuses')] + list(ObservationStatus.choices),
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
severity = forms.ChoiceField(
|
|
required=False,
|
|
choices=[('', 'All Severities')] + list(ObservationSeverity.choices),
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
category = forms.ModelChoiceField(
|
|
required=False,
|
|
queryset=ObservationCategory.objects.filter(is_active=True),
|
|
empty_label="All Categories",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
assigned_department = forms.ModelChoiceField(
|
|
required=False,
|
|
queryset=Department.objects.filter(status='active'),
|
|
empty_label="All Departments",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
assigned_to = forms.ModelChoiceField(
|
|
required=False,
|
|
queryset=User.objects.filter(is_active=True),
|
|
empty_label="All Assignees",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
date_from = forms.DateField(
|
|
required=False,
|
|
widget=forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date',
|
|
})
|
|
)
|
|
|
|
date_to = forms.DateField(
|
|
required=False,
|
|
widget=forms.DateInput(attrs={
|
|
'class': 'form-control',
|
|
'type': 'date',
|
|
})
|
|
)
|
|
|
|
is_anonymous = forms.ChoiceField(
|
|
required=False,
|
|
choices=[
|
|
('', 'All'),
|
|
('yes', 'Anonymous Only'),
|
|
('no', 'Identified Only'),
|
|
],
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
|
|
class ConvertToActionForm(forms.Form):
|
|
"""
|
|
Form for converting an observation to a PX Action.
|
|
"""
|
|
title = forms.CharField(
|
|
max_length=500,
|
|
widget=forms.TextInput(attrs={
|
|
'class': 'form-control',
|
|
})
|
|
)
|
|
|
|
description = forms.CharField(
|
|
widget=forms.Textarea(attrs={
|
|
'class': 'form-control',
|
|
'rows': 4,
|
|
})
|
|
)
|
|
|
|
category = forms.ChoiceField(
|
|
choices=[
|
|
('clinical_quality', 'Clinical Quality'),
|
|
('patient_safety', 'Patient Safety'),
|
|
('service_quality', 'Service Quality'),
|
|
('staff_behavior', 'Staff Behavior'),
|
|
('facility', 'Facility & Environment'),
|
|
('process_improvement', 'Process Improvement'),
|
|
('other', 'Other'),
|
|
],
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
priority = forms.ChoiceField(
|
|
choices=[
|
|
('low', 'Low'),
|
|
('medium', 'Medium'),
|
|
('high', 'High'),
|
|
('critical', 'Critical'),
|
|
],
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
assigned_department = forms.ModelChoiceField(
|
|
required=False,
|
|
queryset=Department.objects.filter(status='active'),
|
|
empty_label="Select department",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
assigned_to = forms.ModelChoiceField(
|
|
required=False,
|
|
queryset=User.objects.filter(is_active=True),
|
|
empty_label="Select assignee",
|
|
widget=forms.Select(attrs={'class': 'form-select'})
|
|
)
|
|
|
|
|
|
class ObservationTrackForm(forms.Form):
|
|
"""
|
|
Form for tracking an observation by its tracking code.
|
|
"""
|
|
tracking_code = forms.CharField(
|
|
max_length=20,
|
|
widget=forms.TextInput(attrs={
|
|
'class': 'form-control form-control-lg',
|
|
'placeholder': 'Enter your tracking code (e.g., OBS-ABC123)',
|
|
'autofocus': True,
|
|
})
|
|
)
|
|
|
|
def clean_tracking_code(self):
|
|
"""Normalize tracking code."""
|
|
code = self.cleaned_data.get('tracking_code', '').strip().upper()
|
|
if not code:
|
|
raise forms.ValidationError("Please enter a tracking code.")
|
|
return code
|