390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""
|
||
Laboratory app forms for healthcare-focused CRUD operations.
|
||
"""
|
||
|
||
from django import forms
|
||
from django.core.exceptions import ValidationError
|
||
from django.utils import timezone
|
||
from .models import (
|
||
LabTest, LabOrder, Specimen, LabResult, QualityControl, ReferenceRange
|
||
)
|
||
from patients.models import PatientProfile
|
||
from accounts.models import User
|
||
|
||
|
||
class LabTestForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating laboratory tests.
|
||
"""
|
||
|
||
def __init__(self, *args, tenant=None, **kwargs):
|
||
"""
|
||
Optionally accept a `tenant` kwarg if you plan on using it for something.
|
||
Re-populates the `test_category` choices from the model’s CHOICES tuple.
|
||
"""
|
||
super().__init__(*args, **kwargs)
|
||
|
||
# Pull the static choices from the model
|
||
all_choices = LabTest.TEST_CATEGORY_CHOICES
|
||
# If you need to adjust per-tenant, do it here. E.g.:
|
||
# filtered = [c for c in all_choices if c[0] in tenant_allowed_codes]
|
||
# self.fields['test_category'].choices = filtered
|
||
|
||
self.fields['test_category'].choices = all_choices
|
||
|
||
class Meta:
|
||
model = LabTest
|
||
fields = [
|
||
'test_code', 'test_name', 'test_description', 'test_category',
|
||
'specimen_type', 'methodology', 'turnaround_time', 'department',
|
||
'fasting_required', 'is_active', 'collection_instructions'
|
||
]
|
||
widgets = {
|
||
'test_category': forms.Select(attrs={'class': 'form-select'}),
|
||
'specimen_type': forms.Select(attrs={'class': 'form-select'}),
|
||
'department': forms.Select(attrs={'class': 'form-select'}),
|
||
'test_description': forms.Textarea(attrs={'rows': 3}),
|
||
'collection_instructions': forms.Textarea(attrs={'rows': 3}),
|
||
'turnaround_time': forms.NumberInput(attrs={'min': 1, 'max': 168}),
|
||
}
|
||
|
||
def clean_test_code(self):
|
||
test_code = self.cleaned_data['test_code']
|
||
# Check for duplicate test codes within the same tenant
|
||
if self.instance.pk:
|
||
existing = LabTest.objects.filter(
|
||
test_code=test_code,
|
||
tenant=self.instance.tenant
|
||
).exclude(pk=self.instance.pk)
|
||
else:
|
||
# For new instances, tenant will be set in the view
|
||
existing = LabTest.objects.filter(test_code=test_code)
|
||
|
||
if existing.exists():
|
||
raise ValidationError('A test with this code already exists.')
|
||
|
||
return test_code.upper()
|
||
|
||
|
||
class ReferenceRangeForm(forms.ModelForm):
|
||
"""
|
||
Form for creating and updating reference ranges.
|
||
"""
|
||
|
||
class Meta:
|
||
model = ReferenceRange
|
||
fields = [
|
||
'test', 'gender', 'age_min', 'age_max', 'range_low',
|
||
'range_high', 'unit', 'range_text'
|
||
]
|
||
widgets = {
|
||
'range_text': forms.Textarea(attrs={'rows': 2}),
|
||
'age_min': forms.NumberInput(attrs={'min': 0, 'max': 120}),
|
||
'age_max': forms.NumberInput(attrs={'min': 0, 'max': 120}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
self.fields['test'].queryset = LabTest.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('test_name')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
age_min = cleaned_data.get('age_min')
|
||
age_max = cleaned_data.get('age_max')
|
||
range_low = cleaned_data.get('range_low')
|
||
range_high = cleaned_data.get('range_high')
|
||
|
||
if age_min is not None and age_max is not None:
|
||
if age_min > age_max:
|
||
raise ValidationError('Minimum age cannot be greater than maximum age.')
|
||
|
||
if range_low is not None and range_high is not None:
|
||
if range_low >= range_high:
|
||
raise ValidationError('Lower limit must be less than upper limit.')
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class LabOrderForm(forms.ModelForm):
|
||
"""
|
||
Form for creating laboratory orders.
|
||
"""
|
||
|
||
class Meta:
|
||
model = LabOrder
|
||
fields = [
|
||
'patient', 'ordering_provider', 'priority', 'clinical_indication',
|
||
'fasting_status', 'special_instructions'
|
||
]
|
||
widgets = {
|
||
'clinical_indication': forms.Textarea(attrs={'rows': 3}),
|
||
'special_instructions': forms.Textarea(attrs={'rows': 2}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
self.fields['patient'].queryset = PatientProfile.objects.filter(
|
||
tenant=user.tenant
|
||
).order_by('last_name', 'first_name')
|
||
|
||
self.fields['test'].queryset = LabTest.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('test_name')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
test = cleaned_data.get('test')
|
||
fasting_status = cleaned_data.get('fasting_status')
|
||
|
||
# Auto-set fasting requirement based on test
|
||
if test and test.fasting_required and not fasting_status:
|
||
cleaned_data['fasting_status'] = True
|
||
self.add_error(
|
||
'fasting_status',
|
||
f'This test ({test.test_name}) requires fasting.'
|
||
)
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class SpecimenForm(forms.ModelForm):
|
||
"""
|
||
Form for creating specimen records.
|
||
"""
|
||
|
||
class Meta:
|
||
model = Specimen
|
||
fields = [
|
||
'order', 'specimen_type', 'collected_datetime',
|
||
'collection_method', 'collection_site', 'volume',
|
||
'container_type', 'quality_notes',
|
||
]
|
||
widgets = {
|
||
'collected_datetime': forms.DateTimeInput(
|
||
attrs={'type': 'datetime-local'},
|
||
format='%Y-%m-%dT%H:%M'
|
||
),
|
||
'quality_notes': forms.Textarea(attrs={'rows': 2}),
|
||
'volume': forms.NumberInput(attrs={'min': 0.1, 'step': 0.1}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
self.fields['order'].queryset = LabOrder.objects.filter(
|
||
tenant=user.tenant,
|
||
# status__in=['PENDING', 'SCHEDULED']
|
||
).select_related('patient', 'test').order_by('-order_datetime')
|
||
|
||
# Set default collection time to now
|
||
if not self.instance.pk:
|
||
self.fields['collected_datetime'].initial = timezone.now()
|
||
|
||
def clean_collected_datetime(self):
|
||
collected_datetime = self.cleaned_data['collected_datetime']
|
||
|
||
if collected_datetime > timezone.now():
|
||
raise ValidationError('Collection time cannot be in the future.')
|
||
|
||
# Check if collection time is too far in the past (more than 7 days)
|
||
seven_days_ago = timezone.now() - timezone.timedelta(days=7)
|
||
if collected_datetime < seven_days_ago:
|
||
raise ValidationError('Collection time cannot be more than 7 days ago.')
|
||
|
||
return collected_datetime
|
||
|
||
|
||
class LabResultForm(forms.ModelForm):
|
||
"""
|
||
Form for entering laboratory results.
|
||
"""
|
||
|
||
class Meta:
|
||
model = LabResult
|
||
fields = [
|
||
'order', 'test', 'result_value', 'result_unit',
|
||
'abnormal_flag', 'is_critical', 'pathologist_comments',
|
||
'technician_comments', 'analyzer'
|
||
]
|
||
widgets = {
|
||
'pathologist_comments': forms.Textarea(attrs={'rows': 3}),
|
||
'technician_comments': forms.Textarea(attrs={'rows': 2}),
|
||
'result_value': forms.NumberInput(attrs={'step': 'any'}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
self.fields['order'].queryset = LabOrder.objects.filter(
|
||
tenant=user.tenant,
|
||
status='IN_PROGRESS'
|
||
).select_related('patient', 'test').order_by('-order_datetime')
|
||
|
||
self.fields['test'].queryset = LabTest.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('test_name')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
order = cleaned_data.get('order')
|
||
test = cleaned_data.get('test')
|
||
result_value = cleaned_data.get('result_value')
|
||
result_text = cleaned_data.get('result_text')
|
||
|
||
# Ensure order and test match
|
||
if order and test and order.test != test:
|
||
raise ValidationError('Selected test must match the order test.')
|
||
|
||
# Ensure either numeric or text result is provided
|
||
if not result_value and not result_text:
|
||
raise ValidationError('Either numeric result value or text result must be provided.')
|
||
|
||
# Auto-populate test from order if not provided
|
||
if order and not test:
|
||
cleaned_data['test'] = order.test
|
||
|
||
return cleaned_data
|
||
|
||
|
||
class QualityControlForm(forms.ModelForm):
|
||
"""
|
||
Form for quality control records.
|
||
"""
|
||
|
||
class Meta:
|
||
model = QualityControl
|
||
fields = [
|
||
'test', 'control_level', 'target_value', 'observed_value',
|
||
'status', 'control_lot', 'analyzer', 'comments'
|
||
]
|
||
widgets = {
|
||
'run_datetime': forms.DateTimeInput(attrs={'type': 'datetime-local'}),
|
||
'comments': forms.Textarea(attrs={'rows': 2}),
|
||
'target_value': forms.NumberInput(attrs={'step': 'any'}),
|
||
'observed_value': forms.NumberInput(attrs={'step': 'any'}),
|
||
}
|
||
|
||
def __init__(self, *args, **kwargs):
|
||
user = kwargs.pop('user', None)
|
||
super().__init__(*args, **kwargs)
|
||
|
||
if user and hasattr(user, 'tenant'):
|
||
self.fields['test'].queryset = LabTest.objects.filter(
|
||
tenant=user.tenant,
|
||
is_active=True
|
||
).order_by('test_name')
|
||
|
||
def clean(self):
|
||
cleaned_data = super().clean()
|
||
target_value = cleaned_data.get('target_value')
|
||
observed_value = cleaned_data.get('observed_value')
|
||
status = cleaned_data.get('status')
|
||
|
||
# Auto-determine status based on values if both are provided
|
||
if target_value is not None and observed_value is not None:
|
||
# Calculate percentage difference
|
||
if target_value != 0:
|
||
percent_diff = abs((observed_value - target_value) / target_value) * 100
|
||
|
||
# Auto-set status based on percentage difference
|
||
if percent_diff <= 5: # Within 5% is acceptable
|
||
if not status:
|
||
cleaned_data['status'] = 'PASS'
|
||
elif percent_diff <= 10: # 5-10% is warning
|
||
if not status:
|
||
cleaned_data['status'] = 'WARNING'
|
||
else: # >10% is fail
|
||
if not status:
|
||
cleaned_data['status'] = 'FAIL'
|
||
|
||
return cleaned_data
|
||
|
||
|
||
# Additional forms for search and filtering
|
||
|
||
class LabOrderSearchForm(forms.Form):
|
||
"""
|
||
Form for searching and filtering laboratory orders.
|
||
"""
|
||
search = forms.CharField(
|
||
max_length=100,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={
|
||
'placeholder': 'Search by patient name, MRN, or test...',
|
||
'class': 'form-control'
|
||
})
|
||
)
|
||
|
||
status = forms.ChoiceField(
|
||
choices=[('', 'All Statuses')] + LabOrder._meta.get_field('status').choices,
|
||
required=False,
|
||
widget=forms.Select(attrs={'class': 'form-control'})
|
||
)
|
||
|
||
priority = forms.ChoiceField(
|
||
choices=[('', 'All Priorities')] + LabOrder._meta.get_field('priority').choices,
|
||
required=False,
|
||
widget=forms.Select(attrs={'class': 'form-control'})
|
||
)
|
||
|
||
date_from = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})
|
||
)
|
||
|
||
date_to = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})
|
||
)
|
||
|
||
|
||
class LabResultSearchForm(forms.Form):
|
||
"""
|
||
Form for searching and filtering laboratory results.
|
||
"""
|
||
search = forms.CharField(
|
||
max_length=100,
|
||
required=False,
|
||
widget=forms.TextInput(attrs={
|
||
'placeholder': 'Search by patient name, MRN, or test...',
|
||
'class': 'form-control'
|
||
})
|
||
)
|
||
|
||
status = forms.ChoiceField(
|
||
choices=[('', 'All Statuses')] + LabResult._meta.get_field('status').choices,
|
||
required=False,
|
||
widget=forms.Select(attrs={'class': 'form-control'})
|
||
)
|
||
|
||
critical_only = forms.BooleanField(
|
||
required=False,
|
||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'})
|
||
)
|
||
|
||
date_from = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})
|
||
)
|
||
|
||
date_to = forms.DateField(
|
||
required=False,
|
||
widget=forms.DateInput(attrs={'type': 'date', 'class': 'form-control'})
|
||
)
|
||
|