Model Choice Field - how to add attributes to the options

I want to add an attribute to model choice field on a form. The intent to do some javascript like hide or show based on a filter. For example, suppose there’s a two state status on each choice, like active or inactive. These are checkboxes, which onChange should make choices with appear or disappear based on whether the checkbox is checked. The attribute to add to each option is generated on each page load, as they are transient. We’re not using APIs for this app just yet.

I’ve been playing around with different solutions I’ve found on-line. They all hinge on extending widget.Select to add the custom field to the attrs. After trying several variations, I found the right search phrase that led me to a solution in the django doc: https://docs.djangoproject.com/en/4.2/ref/forms/fields/#modelmultiplechoicefield. Unfortunately, what’s in the doc isn’t what I’m seeing.

from django import forms

class ToppingSelect(forms.Select):
    def create_option(
        self, name, value, label, selected, index, subindex=None, attrs=None
    ):
        option = super().create_option(
            name, value, label, selected, index, subindex, attrs
        )
        if value:
            option["attrs"]["data-price"] = value.instance.price
        return option

class PizzaForm(forms.ModelForm):
    class Meta:
        model = Pizza
        fields = ["topping"]
        widgets = {"topping": ToppingSelect}

So I adapted the solution:

class CustomSelectWithAttrs(forms.Select):

    def create_option(
        self, name, value, label, selected, index, subindex=None, attrs=None
    ):
        
        option = super().create_option(
            name, value, label, selected, index, subindex, attrs
        )
        print(f'option = {option}')
        print(f'\tattrs = {attrs}')
        print(f'\tvalue = {value}  instance={value.instance}')
        if value:
            print(f'\tstatus = {value.instance.status}')
            option["attrs"]['current-status'] = value.instance.status.state

        print(f'all set now: option = {option}')
        return option

Here’s the form

class MatchFilterForm(ModelForm):
    class Meta:
        model = Match
        fields = {'tutor', 'student', 'match_status', 'date_started', 'comments'}
        exclude = {'lvm_affiliate', 'date_dissolved', }
        labels = {'tutor': 'Tutor pool',
                  'student': 'Student pool',
                  'match_status': "Status",
                  'date_started': "Start date",
                  'comments': 'Comments'}
        widgets = {
            'date_started': DateInput(
                format='%Y-%m-%d',
                attrs={
                    'class': 'form-control',
                    'placeholder': 'Select a date',
                    'type': 'date'
                }
            ),
            'student': CustomSelectWithAttrs,
        }

This renders to


which is great. The html is not so great - the attribute isn’t there:

I’m confused because it looks like the widget’s create_option is working:

The option initializes in the line that starts option = and attrs = {}
The custom attribute are in in attrs in the line that starts all set now.

In your CustomSelectWithAttrs class, you are trying to access value.instance which doesn’t exist because value is just the id of the instance and not the instance itself. You need to get the instance associated with the choice.

class CustomSelectWithAttrs(forms.Select):
    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
        option = super().create_option(name, value, label, selected, index, subindex, attrs)
        if value:
            instance = self.choices.queryset.get(id=value)  
            option["attrs"]['current-status'] = instance.status.state 
        return option

@anefta : Thanks for offering a solution. Unfortunately, I am unable to get it to work. I can confirm the suggestion does get the right instance. It seems there is something else at play, that the attrs key/value pair is being ignored elsewhere.

I changed the code per the suggestion.

 def create_option(
        self, name, value, label, selected, index, subindex=None, attrs=None
    ):

        option = super().create_option(
            name, value, label, selected, index, subindex, attrs
        )
        print(f'\tvalue = {value}  instance={value.instance}')
        if value:
            instance = self.choices.queryset.get(id=value.value)
            # option["attrs"]['current-status'] = value.instance.status.state
            if instance.status is None:
                option["attrs"]["current-status"] = "no-status"
            else:
                option["attrs"]['current-status'] = instance.status.state

        pprint(option)
        return option

For getting the instance out of the choices.queryset, because it’s a ModelChoicesIterator, to get the field value for the filter, I had to use value.value. Unfortunately, the result is the same, attrs appears properly populated, but the attribute doesn’t end up in the rendering. Here’s an example of the value of option at the end of a call:

{'attrs': {'current-status': 'prospect'},
 'index': '23',
 'label': 'a b',
 'name': 'student',
 'selected': False,
 'template_name': 'django/forms/widgets/select_option.html',
 'type': 'select',
 'value': <django.forms.models.ModelChoiceIteratorValue object at 0x109998950>,
 'wrap_label': True}

As an experiment, I tried reassigning option[“value”] = value.value, the end result is the same even though option is

{'attrs': {'current-status': 'prospect'},
 'index': '23',
 'label': 'a b',
 'name': 'student',
 'selected': False,
 'template_name': 'django/forms/widgets/select_option.html',
 'type': 'select',
 'value': 81,
 'wrap_label': True}

After the initial call to option = super().create_option(name, value, label, selected, index, subindex, attrs)

{'attrs': {},
 'index': '23',
 'label': 'a b',
 'name': 'student',
 'selected': False,
 'template_name': 'django/forms/widgets/select_option.html',
 'type': 'select',
 'value': <django.forms.models.ModelChoiceIteratorValue object at 0x114267710>,
 'wrap_label': True}

So it looks as though my specialization is working. Is there something else I need to do that might be a feature of the Select object like I had to do with the ModelChoiceIteratorValue?

As an aside, I do have a question about the explanation. I get that value isn’t an object. But then why does one of the print statements work? The third print statement is print(f'\tvalue = {value} instance={value.instance}') which in the console prints out

(the indented line value = 1 instance=Suzied Q-Tips

I can affirm this is what you’d expect as output for the instance whose id equals 1 (value). Because this matches what I presumed the behavior is from the django doc example, I didn’t think I needed to get the instance from the queryset.

Please ask one question at a time for clarity and avoid images. Use code tags for your full Python and HTML code.

Paste the output as it is - not image.

OK. I’ll follow your advice for how to include results in the future.

But, the issue still remains and I’d appreciate help getting my django code to work as expected. I’m sure it’s something I’m doing. Thanks.

What is the output? Please paste here

{'attrs': {'current-status': 'prospect'},
 'index': '22',
 'label': 'harrells ice cream',
 'name': 'student',
 'selected': False,
 'template_name': 'django/forms/widgets/select_option.html',
 'type': 'select',
 'value': <django.forms.models.ModelChoiceIteratorValue object at 0x10d48f950>,
 'wrap_label': True}

I’ve also tried setting 'value': value.value instead of the ModelChoiceIteratorValue.

Where is your html template code where you render this form?

<div>
                <form action="" method="post">
                  {% csrf_token %}

                  {{ form.tutor|as_crispy_field }}
                  {{ form.student|as_crispy_field }}
                  { form.new_student|as_crispy_field }
                  {{ form.match_status|as_crispy_field }}
                  {{ form.date_started|as_crispy_field }}
                  {{ form.comments|as_crispy_field }}

                  <table>
                    <tr>
                      <td class="col-2">
                        <input type="submit" value="Save" class="btn btn-primary  btn-sm offset-2">
                      </td>
                      <td class="col-2">
                        <input type="reset" value="Reset" class="btn btn-secondary btn-sm offset-2">
                      </td>
                      <td class="col-2">
                        <a href="{% url page_context.cancel_button %}" class="btn btn-danger btn-sm offset-2">
                          Cancel
                        </a>
                      </td>
                    </tr>
                  </table>
                </form>
              </div>

and in case you will want the form’s crispy code, here it is.

        self.helper = FormHelper(self)

        self.helper.form_id = 'get-match'
        self.helper.form_method = 'post'
        self.helper.form_tag = False
        self.helper.form_show_labels = True
        self.helper.form_class = 'form-horizontal'
        self.helper.label_class = 'col-3'
        self.helper.field_class = 'col-6'

My guess is that is a crispy forms issue?! About Custom Form Attributes using Django Crispy Forms - DjangoTricks

Another approach could be to create a custom template for the select option: templates/widgets/custom_select_option.html

<option value="{{ widget.value }}"
        {% for name, value in widget.attrs.items %}
            {{ name }}="{{ value }}"
        {% endfor %}
        {% if widget.selected %}selected{% endif %}>
    {{ widget.label }}
</option>

Then in CustomSelectWithAttrs:

class CustomSelectWithAttrs(forms.Select):
    option_template_name = 'widgets/custom_select_option.html'

    def create_option(
        self, name, value, label, selected, index, subindex=None, attrs=None
    ):
        option = super().create_option(
            name, value, label, selected, index, subindex, attrs
        )
        if value and hasattr(value, 'instance'):
            try:
                option["attrs"]['data-current-status'] = value.instance.status.state
            except AttributeError:
                pass  # Handle cases where the instance might not have the expected attributes
        return option

And now you could select options based on the data-* attribute:

document.querySelectorAll('select[name="student"] option[data-current-status="active"]')

thanks. This is a good solution. Not sure why we had to do all this, but at least it’s done.

Much obliged and thanks for all the patience.

I am glad it worked. Please mark the reply that helped you as a solution in order to also help others. :slight_smile: