HH/apps/observations/forms.py
2026-07-19 12:27:55 +03:00

865 lines
28 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 django.utils.translation import gettext_lazy as _
from apps.accounts.models import User
from apps.core.validators import SAUDI_PHONE_HTML_PATTERN, validate_saudi_phone
from apps.organizations.models import Area, Department, Hospital, LocationType
from apps.organizations.models import Section as OrgSection
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 = [
"hospital",
"location_type",
"area",
"title",
"description",
"location_text",
"incident_datetime",
"reporter_staff_id",
"reporter_name",
"reporter_phone",
"reporter_email",
"national_id",
]
widgets = {
"location_type": forms.Select(
attrs={
"class": "form-select",
}
),
"area": forms.Select(
attrs={
"class": "form-select",
"id": "area_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...",
}
),
"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)",
}
),
"national_id": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "National ID/Iqama (optional)",
}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["location_type"].required = True
self.fields["location_type"].empty_label = None
self.fields["location_type"].choices = [("", "Select Location Type")] + list(LocationType.choices)
self.fields["area"].queryset = Area.objects.none()
self.fields["area"].required = False
self.fields["area"].empty_label = "Select Area (optional)"
hospital_id = self.data.get("hospital") or self.initial.get("hospital")
location_type = self.data.get("location_type") or self.initial.get("location_type")
if hospital_id:
qs = Area.objects.filter(hospital_id=hospital_id, status="active")
if location_type:
qs = qs.filter(location_type=location_type)
self.fields["area"].queryset = qs.order_by("name_en")
# 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 ObservationInternalForm(forms.ModelForm):
"""
Internal form for authenticated staff to create observations.
"""
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=Hospital.objects.filter(status="active"),
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(attrs={"class": "form-select", "id": "hospitalSelect"}),
)
class Meta:
model = Observation
fields = [
"hospital",
"location_type",
"area",
"description",
"title",
"location_text",
"section",
"incident_datetime",
"patient_name",
"patient_national_id",
"patient_phone",
"patient_email",
"assigned_department",
"px_source",
]
widgets = {
"location_type": forms.Select(
attrs={
"class": "form-select",
"id": "locationTypeSelect",
}
),
"area": forms.Select(
attrs={
"class": "form-select",
"id": "areaSelect",
}
),
"description": forms.Textarea(
attrs={
"class": "form-control",
"rows": 5,
"placeholder": "Please describe what you observed in detail...",
}
),
"title": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Brief title (optional)",
}
),
"location_text": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Specific location details (e.g. room number, floor)",
}
),
"incident_datetime": forms.DateTimeInput(
attrs={
"class": "form-control",
"type": "datetime-local",
}
),
"patient_name": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Patient name (optional)",
}
),
"patient_national_id": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "National ID/Iqama (optional)",
}
),
"patient_phone": forms.TextInput(
attrs={
"class": "form-control",
"placeholder": "Patient phone (optional)",
}
),
"patient_email": forms.EmailInput(
attrs={
"class": "form-control",
"placeholder": "Patient email (optional)",
}
),
"assigned_department": forms.Select(
attrs={
"class": "form-select",
}
),
}
section = forms.ModelChoiceField(
label=_("Section"),
queryset=None,
empty_label=_("Select Section"),
required=False,
widget=forms.Select(attrs={"class": "form-select", "id": "sectionSelect"}),
)
def __init__(self, *args, request=None, **kwargs):
self.request = request
self.user = request.user if request else None
super().__init__(*args, **kwargs)
from apps.px_sources.models import PXSource
self.fields["section"].queryset = OrgSection.objects.none()
self.fields["location_type"].required = True
self.fields["location_type"].choices = [("", "Select Location Type")] + list(LocationType.choices)
self.fields["area"].queryset = Area.objects.none()
self.fields["area"].required = False
self.fields["area"].empty_label = "Select Area (optional)"
hospital = None
if self.user:
hospital = getattr(self.user, 'hospital', None)
if hospital:
self.fields["hospital"].initial = hospital
self.fields["hospital"].queryset = Hospital.objects.filter(status="active")
hospital_id = self.data.get("hospital") or (str(hospital.id) if hospital else "")
if not hospital_id and self.initial.get("hospital"):
hospital_id = str(self.initial["hospital"].id) if hasattr(self.initial["hospital"], "id") else str(self.initial["hospital"])
location_type = self.data.get("location_type") or self.initial.get("location_type")
if hospital_id:
qs = Area.objects.filter(hospital_id=hospital_id, status="active")
if location_type:
qs = qs.filter(location_type=location_type)
self.fields["area"].queryset = qs.order_by("name_en")
department_id = self.data.get("assigned_department") or self.initial.get("assigned_department")
if department_id:
self.fields["section"].queryset = OrgSection.objects.filter(
department_id=department_id
).order_by("name_en")
self.fields["px_source"].queryset = PXSource.objects.filter(is_active=True).order_by("name_en")
self.fields["px_source"].empty_label = "Select source (optional)"
self.fields["px_source"].required = False
if not self.instance.pk:
self.initial["incident_datetime"] = timezone.now().strftime("%Y-%m-%dT%H:%M")
if hospital_id:
self.fields["assigned_department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name_en")
else:
self.fields["assigned_department"].queryset = Department.objects.filter(status="active").order_by("name_en")
self.fields["assigned_department"].empty_label = "Select department (optional)"
def clean_description(self):
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")
from apps.core.utils import get_assignable_users
self.fields["assigned_to"].queryset = get_assignable_users(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"}),
)
def __init__(self, *args, hospital=None, **kwargs):
super().__init__(*args, **kwargs)
if hospital:
from apps.core.utils import get_assignable_users
self.fields["assigned_to"].queryset = get_assignable_users(hospital)
self.fields["assigned_department"].queryset = Department.objects.filter(hospital=hospital, status="active")
class ObservationSendToDepartmentForm(forms.Form):
"""
Form for sending an observation to a department for response.
"""
department = forms.ModelChoiceField(
queryset=Department.objects.filter(status="active"),
required=True,
empty_label="Select department",
widget=forms.Select(attrs={"class": "form-select"}),
)
note_en = forms.CharField(
required=False,
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": "Optional note in English...",
}
),
)
note_ar = forms.CharField(
required=False,
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 3,
"placeholder": "ملاحظة اختيارية بالعربية...",
"dir": "rtl",
}
),
)
def __init__(self, *args, hospital=None, **kwargs):
super().__init__(*args, **kwargs)
if hospital:
self.fields["department"].queryset = Department.objects.filter(hospital=hospital, status="active")
class ObservationDepartmentResponseForm(forms.Form):
"""
Form for submitting a department response to an observation.
"""
response_en = forms.CharField(
required=False,
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 6,
"placeholder": "Enter your response in English...",
}
),
)
response_ar = forms.CharField(
required=False,
widget=forms.Textarea(
attrs={
"class": "form-control",
"rows": 6,
"placeholder": "أدخل ردك باللغة العربية...",
"dir": "rtl",
}
),
)
def clean(self):
cleaned_data = super().clean()
response_en = cleaned_data.get("response_en", "").strip()
response_ar = cleaned_data.get("response_ar", "").strip()
if not response_en and not response_ar:
raise forms.ValidationError("Please enter a response in at least one language.")
return cleaned_data
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
class PublicObservationForm(forms.ModelForm):
reporter_name = forms.CharField(
label=_("Reporter Name"),
max_length=200,
required=True,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Your full name")}),
)
reporter_phone = forms.CharField(
label=_("Mobile Number"),
max_length=20,
required=True,
validators=[validate_saudi_phone],
widget=forms.TextInput(attrs={
"class": "form-control",
"placeholder": _("05XXXXXXXX or +9665XXXXXXXX"),
"pattern": SAUDI_PHONE_HTML_PATTERN,
"inputmode": "tel",
}),
)
reporter_email = forms.EmailField(
label=_("Email Address"),
required=False,
widget=forms.EmailInput(attrs={"class": "form-control", "placeholder": _("your@email.com")}),
)
hospital = forms.ModelChoiceField(
label=_("Hospital"),
queryset=None,
empty_label=_("Select Hospital"),
required=True,
widget=forms.Select(attrs={"class": "form-control", "id": "hospital_select"}),
)
location_type = forms.ChoiceField(
label=_("Location Type"),
choices=[("", _("Select Location Type"))] + list(LocationType.choices),
required=True,
widget=forms.Select(attrs={"class": "form-control", "id": "location_type_select"}),
)
department = forms.ModelChoiceField(
label=_("Department (Optional)"),
queryset=Department.objects.none(),
empty_label=_("Select Department"),
required=False,
widget=forms.Select(attrs={"class": "form-control", "id": "department_select"}),
)
section = forms.ModelChoiceField(
label=_("Section (Optional)"),
queryset=OrgSection.objects.none(),
empty_label=_("Select Section"),
required=False,
widget=forms.Select(attrs={"class": "form-control", "id": "section_select"}),
)
area = forms.ModelChoiceField(
label=_("Area (Optional)"),
queryset=Area.objects.none(),
empty_label=_("Select Area"),
required=False,
widget=forms.Select(attrs={"class": "form-control", "id": "area_select"}),
)
title = forms.CharField(
label=_("Title"),
max_length=200,
required=False,
widget=forms.TextInput(attrs={"class": "form-control", "placeholder": _("Brief title (optional)")}),
)
description = forms.CharField(
label=_("Description"),
required=True,
widget=forms.Textarea(attrs={"class": "form-control", "rows": 5, "placeholder": _("Describe the observation in detail...")}),
)
class Meta:
model = Observation
fields = [
"reporter_name",
"reporter_phone",
"reporter_email",
"hospital",
"location_type",
"area",
"department",
"section",
"title",
"description",
"description_en",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
from apps.organizations.models import Hospital
self.fields["hospital"].queryset = Hospital.objects.filter(status="active").order_by("name")
self.fields["section"].queryset = OrgSection.objects.none()
self.fields["area"].queryset = Area.objects.none()
hospital_id = None
if "hospital" in self.initial:
hospital_id = self.initial["hospital"]
elif "hospital" in self.data:
hospital_id = self.data["hospital"]
location_type = self.data.get("location_type") or self.initial.get("location_type")
if hospital_id:
self.fields["department"].queryset = Department.objects.filter(
hospital_id=hospital_id, status="active"
).order_by("name")
area_qs = Area.objects.filter(hospital_id=hospital_id, status="active")
if location_type:
area_qs = area_qs.filter(location_type=location_type)
self.fields["area"].queryset = area_qs.order_by("name_en")
department_id = None
if "department" in self.initial:
department_id = self.initial["department"]
elif "department" in self.data:
department_id = self.data["department"]
if department_id:
self.fields["section"].queryset = OrgSection.objects.filter(
department_id=department_id, status="active"
).order_by("name_en")