import copy from decimal import Decimal from django import forms from django.utils.translation import ugettext as _ import otree.common import otree.constants import otree.models from otree.common import ResponseForException from otree.currency import Currency, RealWorldCurrency from otree.db import models class ModelForm(forms.ModelForm): def _get_method_from_page_or_model(self, method_name): for obj in [self.view, self.instance]: if hasattr(obj, method_name): meth = getattr(obj, method_name) if callable(meth): return meth def __init__(self, *args, view=None, **kwargs): """Special handling for 'choices' argument, BooleanFields, and initial choice: If the user explicitly specifies a None choice (which is usually rendered as '---------'), we should always respect it Otherwise: If the field is a BooleanField: if it's rendered as a Select menu (which it is by default), it should have a None choice If the field is a RadioSelect: it should not have a None choice If the DB field's value is None and the user did not specify an inital value, nothing should be selected by default. This will conceptually match a dropdown. """ # first extract the view instance self.view = view super().__init__(*args, **kwargs) for field_name in self.fields: field = self.fields[field_name] choices_method = self._get_method_from_page_or_model( f'{field_name}_choices' ) if choices_method: choices = choices_method() choices = otree.common.expand_choice_tuples(choices) model_field = self.instance._meta.get_field(field_name) # this is necessary so we don't modify the field for other players model_field_copy = copy.copy(model_field) model_field_copy.choices = choices field = model_field_copy.formfield() self.fields[field_name] = field if isinstance(field.widget, forms.RadioSelect): # Fields with a RadioSelect should be rendered without the # '---------' option, and with nothing selected by default, to # match dropdowns conceptually, and because the '---------' is # not necessary if no item is selected initially. if the # selected item was the None choice, by removing it, nothing # is selected. # maybe they set the widget to Radio, but forgot to specify # choices. that's a mistake, but if oTree validates it, it # should do so somewhere else (because this is just for radio) # need to also check dropdown menus if hasattr(field, 'choices'): choices = field.choices if len(choices) >= 1 and choices[0][0] in {u'', None}: field.choices = choices[1:] self._set_min_max_on_widgets() def _get_field_bound(self, field_name, min_or_max: str): model_field = self.instance._meta.get_field(field_name) min_method = self._get_method_from_page_or_model(f'{field_name}_{min_or_max}') if min_method: return min_method() else: return getattr(model_field, min_or_max, None) def _set_min_max_on_widgets(self): for field_name, field in self.fields.items(): if isinstance(field.widget, forms.NumberInput): for min_or_max in ['min', 'max']: bound = self._get_field_bound(field_name, min_or_max) if isinstance(bound, (Currency, RealWorldCurrency)): bound = Decimal(bound) if bound is not None: field.widget.attrs[min_or_max] = bound def _clean_fields(self): for name, field in self.fields.items(): # value_from_datadict() gets the data from the data dictionaries. # Each widget type knows how to retrieve its own data, because some # widgets split data over several HTML fields. value = field.widget.value_from_datadict( self.data, self.files, self.add_prefix(name) ) try: if isinstance(field, forms.FileField): initial = self.initial.get(name, field.initial) value = field.clean(value, initial) else: value = field.clean(value) self.cleaned_data[name] = value model_field = self.instance._meta.get_field(name) if ( isinstance(model_field, models.BooleanField) and value is None and not model_field.blank ): msg = otree.constants.field_required_msg raise forms.ValidationError(msg) lower = self._get_field_bound(name, 'min') upper = self._get_field_bound(name, 'max') # allow blank=True and min/max to be used together # the field is optional, but # if a value is submitted, it must be within [min,max] if value is not None: if lower is not None and value < lower: msg = _('Value must be greater than or equal to {}.') raise forms.ValidationError(msg.format(lower)) if upper is not None and value > upper: msg = _('Value must be less than or equal to {}.') raise forms.ValidationError(msg.format(upper)) error_message_method = self._get_method_from_page_or_model( f'{name}_error_message' ) if error_message_method: try: error_string = error_message_method(value) except: raise ResponseForException if error_string: raise forms.ValidationError(error_string) except forms.ValidationError as e: self.add_error(name, e) if not self.errors and hasattr(self.view, 'error_message'): try: error = self.view.error_message(self.cleaned_data) except: raise ResponseForException if error: # error could be a string or dict e = forms.ValidationError(error) self.add_error(None, e)