Source code for promapp.forms
from django import forms
from django.db import models
from .models import Questionnaire, Item, QuestionnaireItem, LikertScale, RangeScale, LikertScaleResponseOption, ConstructScale, QuestionnaireItemRule, QuestionnaireItemRuleGroup, CompositeConstructScaleScoring, AIAPIConfiguration
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Div, HTML, Submit, Button
from django.forms import inlineformset_factory
from django.utils.translation import gettext_lazy as _
from parler.forms import TranslatableModelForm
from parler.forms import TranslatedField
from django.utils.translation import get_language
from django.utils.safestring import mark_safe
from django.core.exceptions import ValidationError
import magic
import mimetypes
import os
from .ai_utils.utils import get_utility_function_choices
# Allowed extensions and their MIME types fore the media field.
allowed_types = {
'audio': {
'extensions': ['.mp3', '.wav', '.m4a', '.ogg', '.flac', '.aac'],
'mimetypes': [
'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/x-m4a',
'audio/ogg', 'audio/x-flac', 'audio/flac', 'audio/aac', 'audio/x-aac'
],
},
'video': {
'extensions': ['.mp4', '.mov', '.avi', '.wmv', '.flv', '.mkv'],
'mimetypes': [
'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-ms-wmv',
'video/x-flv', 'video/x-matroska', 'video/mkv'
],
},
'image': {
'extensions': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'],
'mimetypes': [
'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/webp'
],
},
}
all_exts = sum([v['extensions'] for v in allowed_types.values()], [])
all_mimes = sum([v['mimetypes'] for v in allowed_types.values()], [])
[docs]
class QuestionnaireForm(TranslatableModelForm):
name = TranslatedField()
description = TranslatedField(form_class=forms.CharField, widget=forms.Textarea(attrs={'rows': 4}))
questionnaire_answer_interval = forms.IntegerField(
required=False,
min_value=0,
help_text="Time interval between questionnaire attempts. Leave empty for no restriction.",
widget=forms.NumberInput(attrs={
'class': 'interval-value',
'placeholder': 'Enter number (leave empty for no restriction)'
})
)
interval_unit = forms.ChoiceField(
required=False,
choices=[
('seconds', 'Seconds'),
('minutes', 'Minutes'),
('hours', 'Hours'),
('days', 'Days'),
],
initial='seconds',
widget=forms.Select(attrs={'class': 'interval-unit'})
)
questionnaire_order = forms.IntegerField(
required=False,
min_value=0,
help_text="Order in which this questionnaire should be displayed. Leave empty to set as 0.",
widget=forms.NumberInput(attrs={
'placeholder': 'Enter display order (leave empty for 0)'
})
)
questionnaire_redirect = forms.ModelChoiceField(
required=False,
queryset=Questionnaire.objects.all(),
help_text="Questionnaire to redirect to after completion",
empty_label="No redirect"
)
[docs]
class Meta:
model = Questionnaire
fields = ['name', 'description', 'questionnaire_answer_interval', 'questionnaire_order', 'questionnaire_redirect']
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Field('name', css_class='w-full px-3 py-2 border rounded'),
Field('description', css_class='w-full px-3 py-2 border rounded'),
)
# Exclude current questionnaire from redirect choices if editing
if self.instance and self.instance.pk:
self.fields['questionnaire_redirect'].queryset = Questionnaire.objects.exclude(pk=self.instance.pk)
# Set initial values for interval fields if editing
if self.instance and self.instance.pk:
interval = self.instance.questionnaire_answer_interval
if interval and interval > 0: # Only set if interval is greater than 0
if interval < 60:
self.initial['interval_unit'] = 'seconds'
self.initial['questionnaire_answer_interval'] = interval
elif interval < 3600:
self.initial['interval_unit'] = 'minutes'
self.initial['questionnaire_answer_interval'] = interval // 60
elif interval < 86400:
self.initial['interval_unit'] = 'hours'
self.initial['questionnaire_answer_interval'] = interval // 3600
else:
self.initial['interval_unit'] = 'days'
self.initial['questionnaire_answer_interval'] = interval // 86400
[docs]
def clean_questionnaire_answer_interval(self):
"""Clean the questionnaire answer interval field."""
value = self.cleaned_data.get('questionnaire_answer_interval')
# If value is None or empty, that's acceptable - it will be set to 0 in clean()
if value is not None and value < 0:
raise forms.ValidationError("Answer interval cannot be negative.")
return value
[docs]
def clean_questionnaire_order(self):
"""Clean the questionnaire order field."""
value = self.cleaned_data.get('questionnaire_order')
# If value is None or empty, that's acceptable - it will be set to 0 in clean()
if value is not None and value < 0:
raise forms.ValidationError("Questionnaire order cannot be negative.")
return value
[docs]
def clean(self):
cleaned_data = super().clean()
interval_value = cleaned_data.get('questionnaire_answer_interval')
interval_unit = cleaned_data.get('interval_unit')
questionnaire_order = cleaned_data.get('questionnaire_order')
# Ensure questionnaire_answer_interval is never None
if interval_value is not None and interval_unit:
# Convert to seconds based on unit
if interval_unit == 'minutes':
cleaned_data['questionnaire_answer_interval'] = interval_value * 60
elif interval_unit == 'hours':
cleaned_data['questionnaire_answer_interval'] = interval_value * 3600
elif interval_unit == 'days':
cleaned_data['questionnaire_answer_interval'] = interval_value * 86400
# If interval_unit is 'seconds' or anything else, keep the original value
else:
# If no interval is specified, set to 0 (no restriction)
cleaned_data['questionnaire_answer_interval'] = 0
# Ensure questionnaire_order is never None
if questionnaire_order is None:
cleaned_data['questionnaire_order'] = 0
return cleaned_data
[docs]
class ItemSelectionForm(forms.Form):
items = forms.ModelMultipleChoiceField(
queryset=Item.objects.none(), # Will be set in __init__
widget=forms.CheckboxSelectMultiple,
required=False,
label=_("Select Items for Questionnaire")
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Get properly translated items in the current language
current_language = get_language()
self.fields['items'].queryset = Item.objects.language(current_language).all()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Field('items', css_class='space-y-2'),
)
[docs]
class ItemForm(TranslatableModelForm):
name = TranslatedField()
[docs]
class Meta:
model = Item
fields = [
'construct_scale',
'name',
'abbreviated_item_id',
'media',
'response_type',
'likert_response',
'range_response',
'is_required',
'item_missing_value',
'item_better_score_direction',
'item_threshold_score',
'item_minimum_clinical_important_difference',
'item_normative_score_mean',
'item_normative_score_standard_deviation'
]
widgets = {
'construct_scale': forms.SelectMultiple(attrs={
'class': 'construct-scale-select2 w-full',
'data-placeholder': 'Select one or more construct scales...',
'multiple': 'multiple'
}),
'response_type': forms.Select(attrs={'hx-get': '/promapp/get-response-fields/',
'hx-target': '#response-fields',
'hx-trigger': 'change'}),
'likert_response': forms.Select(attrs={'class': 'w-full px-3 py-2 border rounded'}),
'range_response': forms.Select(attrs={'class': 'w-full px-3 py-2 border rounded'}),
'is_required': forms.CheckboxInput(attrs={'class': 'w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2'}),
'item_missing_value': forms.NumberInput(attrs={'step': '0.01', 'placeholder': 'Leave blank to use null for missing values'}),
'item_threshold_score': forms.NumberInput(attrs={'step': '0.01'}),
'item_minimum_clinical_important_difference': forms.NumberInput(attrs={'step': '0.01'}),
'item_normative_score_mean': forms.NumberInput(attrs={'step': '0.01'}),
'item_normative_score_standard_deviation': forms.NumberInput(attrs={'step': '0.01'}),
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.render_unmentioned_fields = False
self.helper.layout = Layout(
# Basic Information Section
Div(
HTML(f'<h3 class="text-lg font-semibold text-gray-800 mb-4">{_("Item Details")}</h3>'),
Field('construct_scale'),
Field('Question Text'),
Field('Question Media File'),
Field('is_required'),
Field('response_type'),
Div(
Field('likert_response', css_class='w-full'),
Field('range_response', css_class='w-full'),
id='response-fields',
css_class='mt-3'
),
Field('item_missing_value'),
css_class='bg-gray-50 p-4 rounded-md mb-6'
),
# Advanced Clinical Settings Section (Collapsible)
Div(
HTML(f'''
<div class="border border-gray-200 rounded-md">
<button type="button"
class="w-full px-4 py-3 text-left bg-gray-100 hover:bg-gray-200 rounded-t-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
onclick="toggleAdvancedSettings()"
id="advanced-settings-toggle">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-800">{_("Advanced Clinical Settings")}</h3>
<svg id="chevron-icon" class="w-5 h-5 text-gray-600 transform transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<p class="text-sm text-gray-600 mt-1">{_("Configure scoring direction, thresholds, and normative values for this item")}</p>
</button>
<div id="advanced-settings-content" class="hidden p-4 space-y-4">
'''),
# Advanced fields with proper spacing
Field('item_better_score_direction', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('item_threshold_score', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('item_minimum_clinical_important_difference', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('item_normative_score_mean', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('item_normative_score_standard_deviation', css_class='w-full px-3 py-2 border rounded mb-4'),
HTML('</div></div>'),
css_class='mb-6'
)
)
# Set the querysets for the response fields
self.fields['likert_response'].queryset = LikertScale.objects.all()
self.fields['range_response'].queryset = RangeScale.objects.all()
# Hide the appropriate fields based on the response type
if self.instance.pk:
if self.instance.response_type != 'Likert':
self.fields['likert_response'].widget = forms.HiddenInput()
if self.instance.response_type != 'Range':
self.fields['range_response'].widget = forms.HiddenInput()
else:
self.fields['likert_response'].widget = forms.HiddenInput()
self.fields['range_response'].widget = forms.HiddenInput()
[docs]
def clean(self):
cleaned_data = super().clean()
# Check if media is being cleared
media_clear = self.data.get('media-clear', False)
if media_clear:
# Set flag to skip media validation in model
self.instance._skip_media_validation = True
# If clearing media, remove it from cleaned_data
cleaned_data['media'] = None
# Also clear from the instance if it exists
if self.instance.pk:
for translation in self.instance.translations.all():
if translation.media:
translation.media = None
else:
# Validate new media uploads (model validation only handles existing files)
media = cleaned_data.get('media')
if media:
name = getattr(media, 'name', None)
if name:
ext = os.path.splitext(name)[1].lower()
if ext not in all_exts:
raise ValidationError({'media': f"Invalid file extension '{ext}' for media. Allowed: {', '.join(all_exts)}"})
# Guess type by extension
expected_type = None
for t, v in allowed_types.items():
if ext in v['extensions']:
expected_type = t
break
# Get MIME type
mime_type = None
try:
mime = magic.Magic(mime=True)
media.seek(0)
mime_type = mime.from_buffer(media.read(2048))
media.seek(0)
except Exception:
import mimetypes
mime_type, _ = mimetypes.guess_type(name)
if not mime_type:
raise ValidationError({'media': f"Could not determine MIME type for file '{name}'."})
valid_mimes = allowed_types[expected_type]['mimetypes'] if expected_type else all_mimes
if mime_type not in valid_mimes:
raise ValidationError({'media': f"File MIME type '{mime_type}' does not match allowed types for extension '{ext}'. Allowed: {', '.join(valid_mimes)}"})
return cleaned_data
[docs]
def save(self, commit=True):
instance = super().save(commit=False)
# Check if media is being cleared
media_clear = self.data.get('media-clear', False)
if media_clear:
# Clear the media field for all translations
if instance.pk:
for translation in instance.translations.all():
if translation.media:
translation.media.delete(save=False)
translation.media = None
if commit:
translation.save()
if commit:
# Store whether this is a new item (before save)
is_new = instance.pk is None
instance.save()
# For ManyToMany fields, we need to save them after the instance is saved
# This is handled by save_m2m() which is called automatically by ModelForm
# But we need to explicitly call it if we're doing commit=False
self.save_m2m()
# After save_m2m(), trigger auto-numbering if item_number is not set
# This is necessary because auto-numbering in the model's save() runs before M2M is saved
# This applies to both NEW items and EXISTING items that don't have a number yet
if not instance.item_number and instance.construct_scale.exists():
# Find the maximum item_number across ALL construct scales this item belongs to
# This ensures the new number doesn't conflict with any existing items
max_item_number = 0
for construct in instance.construct_scale.all():
# Get all items in this construct scale (excluding the current item)
items_in_construct = construct.item_set.exclude(pk=instance.pk).filter(item_number__isnull=False)
if items_in_construct.exists():
# Get the maximum item_number in this construct scale
construct_max = items_in_construct.aggregate(models.Max('item_number'))['item_number__max']
if construct_max:
max_item_number = max(max_item_number, construct_max)
# Assign the next available number (max + 1)
instance.item_number = max_item_number + 1
# Use update to avoid triggering save() again
Item.objects.filter(pk=instance.pk).update(item_number=instance.item_number)
# Refresh the instance to get the updated item_number
instance.refresh_from_db()
return instance
[docs]
class ConstructScaleForm(forms.ModelForm):
[docs]
class Meta:
model = ConstructScale
fields = [
'name',
'instrument_name',
'instrument_version',
'scale_better_score_direction',
'scale_threshold_score',
'scale_minimum_clinical_important_difference',
'scale_normative_score_mean',
'scale_normative_score_standard_deviation'
]
widgets = {
'scale_threshold_score': forms.NumberInput(attrs={'step': '0.01'}),
'scale_minimum_clinical_important_difference': forms.NumberInput(attrs={'step': '0.01'}),
'scale_normative_score_mean': forms.NumberInput(attrs={'step': '0.01'}),
'scale_normative_score_standard_deviation': forms.NumberInput(attrs={'step': '0.01'}),
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
# Basic Information Section
Div(
HTML(f'<h3 class="text-lg font-semibold text-gray-800 mb-4">{_("Basic Information")}</h3>'),
Field('name', css_class='w-full px-3 py-2 border rounded'),
Field('instrument_name', css_class='w-full px-3 py-2 border rounded'),
Field('instrument_version', css_class='w-full px-3 py-2 border rounded'),
css_class='bg-gray-50 p-4 rounded-md mb-6'
),
# Advanced Settings Section (Collapsible)
Div(
HTML(f'''
<div class="border border-gray-200 rounded-md">
<button type="button"
class="w-full px-4 py-3 text-left bg-gray-100 hover:bg-gray-200 rounded-t-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
onclick="toggleAdvancedSettings()"
id="advanced-settings-toggle">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-800">{_("Advanced Clinical Settings")}</h3>
<svg id="chevron-icon" class="w-5 h-5 text-gray-600 transform transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<p class="text-sm text-gray-600 mt-1">{_("Configure scoring direction, thresholds, and normative values")}</p>
</button>
<div id="advanced-settings-content" class="hidden p-4 space-y-4">
'''),
# Advanced fields with proper spacing
Field('scale_better_score_direction', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('scale_threshold_score', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('scale_minimum_clinical_important_difference', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('scale_normative_score_mean', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('scale_normative_score_standard_deviation', css_class='w-full px-3 py-2 border rounded mb-4'),
HTML('</div></div>'),
css_class='mb-6'
)
)
[docs]
class LikertScaleResponseOptionForm(TranslatableModelForm):
option_text = TranslatedField()
option_media = TranslatedField(required=False)
[docs]
class Meta:
model = LikertScaleResponseOption
fields = ['option_order', 'option_text', 'option_value', 'option_emoji', 'option_media']
widgets = {
'option_emoji': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded emoji-input',
'placeholder': '😊 Click to select emoji',
'data-emoji-picker': 'true',
'maxlength': '10'
}),
}
# Update the LikertScaleResponseOptionFormSet to use the custom TranslatableModelForm
LikertScaleResponseOptionFormSet = inlineformset_factory(
LikertScale,
LikertScaleResponseOption,
form=LikertScaleResponseOptionForm,
fields=('option_order', 'option_value', 'option_text', 'option_emoji', 'option_media'),
extra=1,
can_delete=True
)
# Excluding response fields as they will be filled when patient responds
[docs]
class RangeScaleForm(TranslatableModelForm):
min_value_text = TranslatedField()
max_value_text = TranslatedField()
[docs]
class Meta:
model = RangeScale
fields = ['range_scale_name', 'min_value', 'max_value', 'increment', 'min_value_text', 'max_value_text']
widgets = {
'min_value': forms.NumberInput(attrs={'step': '0.01'}),
'max_value': forms.NumberInput(attrs={'step': '0.01'}),
'increment': forms.NumberInput(attrs={'step': '0.01'}),
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Field('range_scale_name', css_class='w-full px-3 py-2 border rounded'),
Field('min_value', css_class='w-full px-3 py-2 border rounded'),
Field('max_value', css_class='w-full px-3 py-2 border rounded'),
Field('increment', css_class='w-full px-3 py-2 border rounded'),
Field('min_value_text', css_class='w-full px-3 py-2 border rounded'),
Field('max_value_text', css_class='w-full px-3 py-2 border rounded'),
)
[docs]
class QuestionnaireResponseForm(forms.Form):
"""
Form for handling questionnaire responses.
This form is dynamically created based on the questionnaire items.
"""
[docs]
def __init__(self, *args, **kwargs):
questionnaire_items = kwargs.pop('questionnaire_items', [])
super().__init__(*args, **kwargs)
# Store questionnaire_items as an instance attribute for use in clean method
self.questionnaire_items = questionnaire_items
# Get the current language
current_language = get_language()
for qi in questionnaire_items:
if qi.item.response_type == 'Text':
self.fields[f'response_{qi.id}'] = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'w-full px-4 py-3 text-lg border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'rows': 3,
'placeholder': _('Enter your response here...')
})
)
elif qi.item.response_type == 'Number':
self.fields[f'response_{qi.id}'] = forms.IntegerField(
required=False,
widget=forms.NumberInput(attrs={
'class': 'w-full px-4 py-3 text-lg border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'placeholder': _('Enter a number...')
})
)
elif qi.item.response_type == 'Likert':
# Get options with fallback to default language
try:
options = qi.item.likert_response.likertscaleresponseoption_set.language(current_language)
except:
# If translation not found, fall back to default language (en-gb)
options = qi.item.likert_response.likertscaleresponseoption_set.language('en-gb')
choices = [(option.option_value, option.option_text) for option in options]
self.fields[f'response_{qi.id}'] = forms.ChoiceField(
required=False,
choices=choices,
widget=forms.RadioSelect(attrs={
'class': 'likert-options'
})
)
elif qi.item.response_type == 'Range':
self.fields[f'response_{qi.id}'] = forms.IntegerField(
required=False,
min_value=qi.item.range_response.min_value,
max_value=qi.item.range_response.max_value,
widget=forms.NumberInput(attrs={
'class': 'w-full px-4 py-3 text-lg border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'type': 'range',
'min': qi.item.range_response.min_value,
'max': qi.item.range_response.max_value,
'step': qi.item.range_response.increment
})
)
elif qi.item.response_type == 'Media':
self.fields[f'response_media_{qi.id}'] = forms.FileField(
required=False,
widget=forms.FileInput(attrs={'accept': 'audio/*,video/*'})
)
[docs]
def clean(self):
cleaned_data = super().clean()
# Convert empty strings to None and ensure all questions have a value (None if unanswered)
for qi in self.questionnaire_items:
if qi.item.response_type == 'Media':
field_name = f'response_media_{qi.id}'
else:
field_name = f'response_{qi.id}'
value = cleaned_data.get(field_name)
if value == '' or value is None:
cleaned_data[field_name] = None
return cleaned_data
[docs]
class QuestionnaireItemRuleForm(forms.ModelForm):
"""
Form for creating and editing questionnaire item rules.
"""
[docs]
class Meta:
model = QuestionnaireItemRule
fields = ['dependent_item', 'operator', 'comparison_value', 'logical_operator', 'rule_order']
widgets = {
'operator': forms.Select(attrs={
'class': 'w-full px-3 py-2 border rounded',
'hx-get': '/promapp/validate-rule-operator/',
'hx-trigger': 'change',
'hx-target': '#comparison-value-container',
'hx-swap': 'innerHTML'
}),
'comparison_value': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'hx-get': '/promapp/validate-comparison-value/',
'hx-trigger': 'change',
'hx-target': '#comparison-value-feedback',
'hx-swap': 'innerHTML'
}),
'logical_operator': forms.Select(attrs={
'class': 'w-full px-3 py-2 border rounded',
'hx-get': '/promapp/validate-logical-operator/',
'hx-trigger': 'change',
'hx-target': '#logical-operator-feedback',
'hx-swap': 'innerHTML'
}),
'rule_order': forms.NumberInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'min': '1',
'hx-get': '/promapp/validate-rule-order/',
'hx-trigger': 'change',
'hx-target': '#rule-order-feedback',
'hx-swap': 'innerHTML'
}),
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
questionnaire_item = getattr(self.instance, 'questionnaire_item', None) or kwargs.get('initial', {}).get('questionnaire_item')
print('[DEBUG] __init__: instance.questionnaire_item =', getattr(self.instance, 'questionnaire_item', None))
print('[DEBUG] __init__: initial[questionnaire_item] =', kwargs.get('initial', {}).get('questionnaire_item'))
if questionnaire_item:
# Get all items from the same questionnaire
base_queryset = QuestionnaireItem.objects.filter(
questionnaire=questionnaire_item.questionnaire
)
# If we have a question number, filter for items that come before this one
if questionnaire_item.question_number is not None:
base_queryset = base_queryset.filter(
question_number__lt=questionnaire_item.question_number
)
# Order by question number for proper sorting in the dropdown
self.fields['dependent_item'].queryset = base_queryset.order_by('question_number')
else:
self.fields['dependent_item'].queryset = QuestionnaireItem.objects.none()
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('dependent_item',
css_class='w-full px-3 py-2 border rounded',
hx_get='/promapp/validate-dependent-item/',
hx_trigger='change',
hx_target='#dependent-item-feedback',
hx_swap='innerHTML'),
Div(id='dependent-item-feedback'),
css_class='mb-4'
),
Div(
Field('operator'),
Div(id='comparison-value-container'),
css_class='mb-4'
),
Div(
Field('comparison_value'),
Div(id='comparison-value-feedback'),
css_class='mb-4'
),
Div(
Field('logical_operator'),
Div(id='logical-operator-feedback'),
css_class='mb-4'
),
Div(
Field('rule_order'),
Div(id='rule-order-feedback'),
css_class='mb-4'
),
)
# Add HTMX attributes to dependent_item field
self.fields['dependent_item'].widget.attrs.update({
'hx-get': '/promapp/validate-dependent-item/',
'hx-trigger': 'change',
'hx-target': '#dependent-item-feedback',
'hx-swap': 'innerHTML'
})
[docs]
def clean(self):
cleaned_data = super().clean()
dependent_item = cleaned_data.get('dependent_item')
operator = cleaned_data.get('operator')
comparison_value = cleaned_data.get('comparison_value')
questionnaire_item = self.initial.get('questionnaire_item')
if self.instance.pk:
questionnaire_item = getattr(self.instance, 'questionnaire_item', questionnaire_item)
# Check if the questionnaire item being targeted by this rule is required
if questionnaire_item and questionnaire_item.item.is_required:
raise forms.ValidationError(
f'Rule creation not allowed: The question "{questionnaire_item.item.name}" is marked as required for scoring. '
f'Rules cannot be created for required items as they might prevent the item from being displayed, '
f'which would make score calculation impossible. Please unmark this item as required first if you want to add visibility rules.'
)
if dependent_item and questionnaire_item:
# Ensure dependent item is from the same questionnaire
if dependent_item.questionnaire != questionnaire_item.questionnaire:
raise forms.ValidationError(
f'Rule validation failed: The dependent question "{dependent_item.item.name}" belongs to a different questionnaire than the current question "{questionnaire_item.item.name}". Please select a question from the same questionnaire.'
)
# Validate comparison value based on the dependent item's response type
if dependent_item.item.response_type == 'Number':
try:
float(comparison_value)
except (ValueError, TypeError):
raise forms.ValidationError(
f'Rule validation failed: The comparison value "{comparison_value}" must be a valid number for the numeric question "{dependent_item.item.name}".'
)
elif dependent_item.item.response_type == 'Likert':
try:
float_value = float(comparison_value)
likert_options = dependent_item.item.likert_response.likertscaleresponseoption_set.all()
valid_values = [option.option_value for option in likert_options]
if float_value not in valid_values:
valid_values_str = ', '.join(map(str, valid_values))
raise forms.ValidationError(
f'Rule validation failed: The comparison value "{comparison_value}" is not a valid option for the Likert scale question "{dependent_item.item.name}". Valid values are: {valid_values_str}'
)
except (ValueError, TypeError):
raise forms.ValidationError(
f'Rule validation failed: The comparison value "{comparison_value}" must be a valid number for the Likert scale question "{dependent_item.item.name}".'
)
elif dependent_item.item.response_type == 'Range':
try:
value = float(comparison_value)
range_scale = dependent_item.item.range_response
if not (range_scale.min_value <= value <= range_scale.max_value):
raise forms.ValidationError(
f'Rule validation failed: The comparison value "{comparison_value}" must be between {range_scale.min_value} and {range_scale.max_value} for the range scale question "{dependent_item.item.name}".'
)
except (ValueError, TypeError):
raise forms.ValidationError(
f'Rule validation failed: The comparison value "{comparison_value}" must be a valid number for the range scale question "{dependent_item.item.name}".'
)
return cleaned_data
[docs]
class QuestionnaireItemRuleGroupForm(forms.ModelForm):
"""
Form for creating and editing questionnaire item rule groups.
"""
rules = forms.ModelMultipleChoiceField(
queryset=QuestionnaireItemRule.objects.none(),
widget=forms.CheckboxSelectMultiple(attrs={
'class': 'space-y-2',
'hx-get': '/promapp/validate-rule-selection/',
'hx-trigger': 'change',
'hx-target': '#rule-selection-feedback',
'hx-swap': 'innerHTML'
}),
required=True,
label=_("Select Rules")
)
[docs]
class Meta:
model = QuestionnaireItemRuleGroup
fields = ['group_order']
widgets = {
'group_order': forms.NumberInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'min': '1',
'hx-get': '/promapp/validate-group-order/',
'hx-trigger': 'change',
'hx-target': '#group-order-feedback',
'hx-swap': 'innerHTML'
}),
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Use questionnaire_item from instance or initial
questionnaire_item = getattr(self.instance, 'questionnaire_item', None) or kwargs.get('initial', {}).get('questionnaire_item')
if questionnaire_item:
self.fields['rules'].queryset = QuestionnaireItemRule.objects.filter(
questionnaire_item=questionnaire_item
)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('group_order'),
Div(id='group-order-feedback'),
css_class='mb-4'
),
Div(
Field('rules'),
Div(id='rule-selection-feedback'),
css_class='mb-4'
),
)
[docs]
def clean(self):
cleaned_data = super().clean()
rules = cleaned_data.get('rules')
questionnaire_item = self.instance.questionnaire_item if self.instance.pk else self.initial.get('questionnaire_item')
# Check if the questionnaire item being targeted by rule groups is required
if questionnaire_item and questionnaire_item.item.is_required:
raise forms.ValidationError(
f'Rule group creation not allowed: The question "{questionnaire_item.item.name}" is marked as required for scoring. '
f'Rule groups cannot be created for required items as they might prevent the item from being displayed, '
f'which would make score calculation impossible. Please unmark this item as required first if you want to add visibility rule groups.'
)
if rules and questionnaire_item:
# Ensure all selected rules belong to the same questionnaire item
for rule in rules:
if rule.questionnaire_item != questionnaire_item:
raise forms.ValidationError(_("All rules must belong to the same questionnaire item."))
return cleaned_data
[docs]
def save(self, commit=True):
instance = super().save(commit=False)
if commit:
instance.save()
# Update the many-to-many relationship
instance.rules.set(self.cleaned_data['rules'])
return instance
# Translation Forms
[docs]
class ItemTranslationForm(TranslatableModelForm):
"""
Form for translating Item model.
"""
name = TranslatedField(
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'placeholder': 'Enter item name'
})
)
media = TranslatedField(
required=False,
widget=forms.ClearableFileInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
})
)
[docs]
def clean(self):
cleaned_data = super().clean()
# Check if media is being cleared (ClearableFileInput returns False when cleared)
media_clear = self.data.get('media-clear', False)
if media_clear:
# Set flag to skip media validation in model
self.instance._skip_media_validation = True
# Convert False to None for proper FileField clearing
cleaned_data['media'] = None
return cleaned_data
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('name', wrapper_class='mb-4'),
Field('media', wrapper_class='mb-4'),
css_class='space-y-4'
)
)
[docs]
class QuestionnaireTranslationForm(TranslatableModelForm):
"""
Form for translating Questionnaire model.
"""
name = TranslatedField(
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'placeholder': 'Enter questionnaire name'
})
)
description = TranslatedField(
form_class=forms.CharField,
widget=forms.Textarea(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'rows': 4,
'placeholder': 'Enter questionnaire description'
})
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('name', wrapper_class='mb-4'),
Field('description', wrapper_class='mb-4'),
css_class='space-y-4'
)
)
[docs]
class LikertScaleResponseOptionTranslationForm(TranslatableModelForm):
"""
Form for translating LikertScaleResponseOption model.
"""
option_text = TranslatedField(
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'placeholder': 'Enter option text'
})
)
option_media = TranslatedField(
required=False,
widget=forms.ClearableFileInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
})
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('option_text', wrapper_class='mb-4'),
Field('option_media', wrapper_class='mb-4'),
css_class='space-y-4'
)
)
[docs]
class RangeScaleTranslationForm(TranslatableModelForm):
"""
Form for translating RangeScale model.
"""
min_value_text = TranslatedField(
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'placeholder': 'Enter min value text'
})
)
max_value_text = TranslatedField(
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 text-lg border-2 border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent',
'placeholder': 'Enter max value text'
})
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field('min_value_text', wrapper_class='mb-4'),
Field('max_value_text', wrapper_class='mb-4'),
css_class='space-y-4'
)
)
[docs]
class TranslationSearchForm(forms.Form):
"""
Form for searching and filtering translation lists.
"""
search = forms.CharField(
required=False,
widget=forms.TextInput(attrs={
'class': 'w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'placeholder': _('Search...'),
'hx-get': '', # Will be set in the view
'hx-trigger': 'keyup changed delay:500ms',
'hx-target': '#translation-table',
'hx-swap': 'innerHTML'
})
)
language_filter = forms.ChoiceField(
required=False,
widget=forms.Select(attrs={
'class': 'w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'hx-get': '', # Will be set in the view
'hx-trigger': 'change',
'hx-target': '#translation-table',
'hx-swap': 'innerHTML'
})
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Create choices for language filter
from django.conf import settings
language_choices = [('', _('All Languages'))]
for lang_code, lang_name in settings.LANGUAGES:
language_choices.extend([
(f'{lang_code}_translated', _(f'{lang_name} (Translated)')),
(f'{lang_code}_untranslated', _(f'{lang_name} (Not Translated)'))
])
self.fields['language_filter'].choices = language_choices
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Div(
Field('search'),
css_class='w-full md:w-2/3'
),
Div(
Field('language_filter'),
css_class='w-full md:w-1/3'
),
css_class='flex flex-col md:flex-row gap-4 mb-4'
)
)
[docs]
class ConstructEquationForm(forms.ModelForm):
"""
Form for managing the equation of a construct scale.
"""
scale_equation = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'rows': 4,
'class': 'w-full px-4 py-3 text-base border-2 border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 font-mono',
'placeholder': 'Enter equation using {q1}, {q2}, etc. for question references',
'hx-get': '/promapp/validate-equation/',
'hx-trigger': 'keyup changed delay:500ms',
'hx-target': '#equation-validation',
'hx-include': '[name="scale_id"]'
})
)
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
[docs]
def clean_scale_equation(self):
equation = self.cleaned_data.get('scale_equation')
if equation:
# Basic validation will be done in the model's validate_scale_equation method
pass
return equation
[docs]
class CompositeConstructScaleScoringForm(forms.ModelForm):
"""
Form for creating and editing composite construct scale scoring configurations.
"""
construct_scales = forms.ModelMultipleChoiceField(
queryset=ConstructScale.objects.all(),
widget=forms.SelectMultiple(attrs={
'class': 'form-control',
'size': '8',
'multiple': True
}),
help_text="Hold Ctrl/Cmd to select multiple construct scales. Type to search within the list."
)
[docs]
class Meta:
model = CompositeConstructScaleScoring
fields = [
'composite_construct_scale_name',
'construct_scales',
'scoring_type',
'composite_construct_score_direction',
'composite_construct_scale_threshold_score',
'composite_construct_scale_minimum_clinical_important_difference',
'composite_construct_scale_normative_score_mean',
'composite_construct_scale_normative_score_standard_deviation'
]
widgets = {
'composite_construct_scale_name': forms.TextInput(attrs={
'class': 'w-full px-4 py-3 text-base border-2 border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500',
'placeholder': 'Enter composite construct scale name'
}),
'scoring_type': forms.Select(attrs={
'class': 'w-full px-4 py-3 text-base border-2 border-gray-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500'
}),
'composite_construct_score_direction': forms.Select(attrs={
'class': 'w-full px-3 py-2 border rounded'
}),
'composite_construct_scale_threshold_score': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'placeholder': 'Enter threshold score'
}),
'composite_construct_scale_minimum_clinical_important_difference': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'placeholder': 'Enter minimum important difference'
}),
'composite_construct_scale_normative_score_mean': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'placeholder': 'Enter normative score mean'
}),
'composite_construct_scale_normative_score_standard_deviation': forms.TextInput(attrs={
'class': 'w-full px-3 py-2 border rounded',
'placeholder': 'Enter normative score standard deviation'
})
}
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
# Basic Information Section
Div(
HTML(f'<h3 class="text-lg font-semibold text-gray-800 mb-4">{_("Basic Information")}</h3>'),
Field('composite_construct_scale_name', css_class='w-full px-4 py-3 border-2 border-gray-300 rounded-lg'),
Field('scoring_type', css_class='w-full px-4 py-3 border-2 border-gray-300 rounded-lg'),
css_class='bg-gray-50 p-4 rounded-md mb-6'
),
# Construct Scales Selection Section
Div(
HTML(f'<h3 class="text-lg font-semibold text-gray-800 mb-4">{_("Select Construct Scales")}</h3>'),
Field('construct_scales', css_class='form-control'),
HTML('<div class="mt-2 text-sm text-gray-600">'),
HTML('Hold Ctrl/Cmd and click to select multiple items. At least 2 construct scales are required.'),
HTML('</div>'),
css_class='bg-gray-50 p-4 rounded-md mb-6'
),
# Advanced Clinical Settings Section (Collapsible)
Div(
HTML(f'''
<div class="border border-gray-200 rounded-md">
<button type="button"
class="w-full px-4 py-3 text-left bg-gray-100 hover:bg-gray-200 rounded-t-md focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200"
onclick="toggleAdvancedSettings()"
id="advanced-settings-toggle">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-800">{_("Advanced Clinical Settings")}</h3>
<svg id="chevron-icon" class="w-5 h-5 text-gray-600 transform transition-transform duration-200 rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
<p class="text-sm text-gray-600 mt-1">{_("Configure scoring direction, thresholds, and normative values for this composite scale")}</p>
</button>
<div id="advanced-settings-content" class="p-4 space-y-4">
'''),
# Advanced fields with proper spacing
Field('composite_construct_score_direction', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('composite_construct_scale_threshold_score', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('composite_construct_scale_minimum_clinical_important_difference', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('composite_construct_scale_normative_score_mean', css_class='w-full px-3 py-2 border rounded mb-4'),
Field('composite_construct_scale_normative_score_standard_deviation', css_class='w-full px-3 py-2 border rounded mb-4'),
HTML('</div></div>'),
css_class='mb-6'
)
)
# Update queryset to show construct scales ordered by instrument name, then construct name
# Use COALESCE to handle NULL instrument_name values (put them at the end)
from django.db.models import Value
from django.db.models.functions import Coalesce
self.fields['construct_scales'].queryset = ConstructScale.objects.annotate(
instrument_name_sort=Coalesce('instrument_name', Value('zzz')) # NULL values sort last
).order_by('instrument_name_sort', 'name')
# Customize the choice labels to include more information
choices = []
for construct in self.fields['construct_scales'].queryset:
label = construct.name
if construct.instrument_name:
label += f" ({construct.instrument_name}"
if construct.instrument_version:
label += f" v{construct.instrument_version}"
label += ")"
choices.append((construct.id, label))
self.fields['construct_scales'].choices = choices
[docs]
def clean_construct_scales(self):
construct_scales = self.cleaned_data.get('construct_scales')
if not construct_scales:
raise forms.ValidationError("Please select at least one construct scale.")
if len(construct_scales) < 2:
raise forms.ValidationError("A composite score requires at least two construct scales.")
return construct_scales
[docs]
class StaffQuestionnaireResponseForm(forms.Form):
"""
Form for staff to submit questionnaire responses on behalf of patients.
Includes patient selection and custom submission date.
"""
patient = forms.CharField(
required=True,
widget=forms.HiddenInput(attrs={'id': 'patient-select-hidden'}),
label=_('Patient')
)
submission_date = forms.DateTimeField(
required=True,
widget=forms.DateTimeInput(attrs={
'type': 'datetime-local',
'class': 'w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500'
}),
label=_('Submission Date and Time')
)
[docs]
def __init__(self, *args, **kwargs):
questionnaire_items = kwargs.pop('questionnaire_items', [])
super().__init__(*args, **kwargs)
# Store questionnaire_items as an instance attribute
self.questionnaire_items = questionnaire_items
# Get the current language
current_language = get_language()
# Add response fields for each questionnaire item
for qi in questionnaire_items:
if qi.item.response_type == 'Text':
self.fields[f'response_{qi.id}'] = forms.CharField(
required=False,
widget=forms.Textarea(attrs={
'class': 'w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'rows': 3,
'placeholder': _('Enter response here...')
})
)
elif qi.item.response_type == 'Number':
self.fields[f'response_{qi.id}'] = forms.IntegerField(
required=False,
widget=forms.NumberInput(attrs={
'class': 'w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'placeholder': _('Enter a number...')
})
)
elif qi.item.response_type == 'Likert':
# Get options with fallback to default language
try:
options = qi.item.likert_response.likertscaleresponseoption_set.language(current_language)
except:
options = qi.item.likert_response.likertscaleresponseoption_set.language('en-gb')
choices = [('', '-- Select --')] + [(option.option_value, option.option_text) for option in options]
self.fields[f'response_{qi.id}'] = forms.ChoiceField(
required=False,
choices=choices,
widget=forms.RadioSelect(attrs={
'class': 'likert-options'
})
)
elif qi.item.response_type == 'Range':
self.fields[f'response_{qi.id}'] = forms.IntegerField(
required=False,
min_value=qi.item.range_response.min_value,
max_value=qi.item.range_response.max_value,
widget=forms.NumberInput(attrs={
'class': 'w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500',
'type': 'range',
'min': qi.item.range_response.min_value,
'max': qi.item.range_response.max_value,
'step': qi.item.range_response.increment
})
)
elif qi.item.response_type == 'Media':
self.fields[f'response_media_{qi.id}'] = forms.FileField(
required=False,
widget=forms.FileInput(attrs={'accept': 'audio/*,video/*'})
)
[docs]
def clean_patient(self):
patient_id = self.cleaned_data.get('patient')
if not patient_id:
raise forms.ValidationError(_('Please select a patient.'))
from patientapp.models import Patient
try:
patient = Patient.objects.get(id=patient_id)
return patient
except Patient.DoesNotExist:
raise forms.ValidationError(_('Invalid patient selected.'))
[docs]
def clean(self):
cleaned_data = super().clean()
# Convert empty strings to None
for qi in self.questionnaire_items:
if qi.item.response_type == 'Media':
field_name = f'response_media_{qi.id}'
else:
field_name = f'response_{qi.id}'
value = cleaned_data.get(field_name)
if value == '':
cleaned_data[field_name] = None
return cleaned_data
[docs]
class AIAPIConfigurationForm(forms.ModelForm):
"""
Form for AIAPIConfiguration with dynamic utility function choices.
"""
utility_function_path = forms.ChoiceField(
label='Utility Function',
help_text='Select the utility function to use for this AI capability',
widget=forms.Select(attrs={'class': 'form-control'})
)
[docs]
class Meta:
model = AIAPIConfiguration
fields = ['ai_provider', 'ai_capability', 'utility_function_path', 'api_url', 'api_key_environment_variable_name']
[docs]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Dynamically populate utility function choices
choices = get_utility_function_choices()
if not choices:
choices = [('', 'No utility functions available')]
else:
choices = [('', 'Select a utility function')] + list(choices)
self.fields['utility_function_path'].choices = choices