Add data attributes to autocomplete_fields option items

Hi!

I have a fairly complex working admin customization that works well with ordinary foreign key fields.

First, there is a mixin class that adds data attributes to options of any forms.widgets.Select-based class:

class AddDataAttrsToOptionsSelectMixin:

    def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
        option = super().create_option(name, value, label, selected, index, subindex, attrs)
        # self.select_option_data is initialized in add_data_attr_options_to_widget()
        # with a dictionary of data attributes.
        for data_attr, values in self.select_option_data.items():
            option['attrs'][data_attr] = values[option['value']]
        return option

Second, there is a function that applies the mixin to the given widget dynamically during runtime:

def add_data_attr_options_to_widget(widget, data):
    widget.select_option_data = data
    widget_cls = widget.__class__
    widget.__class__ = type(f'{widget_cls.__name__}WithDataAttrs',
        (AddDataAttrsToOptionsSelectMixin, widget_cls), {})

Suppose you have a Customer model with notes field that is used in a SalesOrder model:

class Customer(models.Model):
    notes = models.TextField(blank=True)

class SalesOrder(models.Model):
    customer = models.ForeignKey(Customer)

then you would add notes to the customers in the customer drop-down in sales order admin as follows:

class SalesOrderAdminModelForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        data_customer_notes = {
            'data-notes': dict(Customer.objects.values_list('id', 'notes'))
        }
        data_customer_notes['data-notes'][''] = False  # empty option
        add_data_attr_options_to_widget(self.fields['customer'].widget, data_customer_notes)

@admin.register(SalesOrder)
class SalesOrderAdmin(admin.ModelAdmin):
    form = SalesOrderAdminModelForm

and you could use the notes in custom admin JavaScript as follows:

var $selected = $('#id_customer').find(":selected")
var noteText = $selected.attr('data-notes')
alert('Selected customer note: ' + noteText)

So far, so good, this works very well.

The problem is that there are many customers and the customers field should really use autocomplete in admin:

@admin.register(SalesOrder)
class SalesOrderAdmin(admin.ModelAdmin):
    autocomplete_fields = ('customer',)
    ...

Is there a way to add attributes to the results of the admin autocomplete AJAX endpoint and assure that the attributes are propagated to option items with Selec2, so that the JavaScript snippet from above will continue to work?

var $selected = $('#id_customer').find(":selected")
var noteText = $selected.attr('data-notes')
alert('Selected customer note: ' + noteText)

Thank you in advance for any hints!

I dug in the source a little and here’s what I found:

  • There is currently no way to customize the results’ fields of the autocomplete AJAX endpoint, see AutocompleteJsonView.
  • If I manually patch AutocompleteJsonView as follows:
    return JsonResponse({ 'results': [ {'id': str(obj.pk), 'text': str(obj), 'notes': 'Howdy!'}
    then the added field notes is visible in the items of the $('#id_customer').select2('data') result array.

Should I monkey-patch AutocompleteJsonView or propose a change to autocomplete_fields implementation to allow data attributes customization?

Rather than monkey-patch it, subclass the view and use that view. You need to know what URLs are being issued by the client, but there’s nothing stopping you from replacing the admin-supplied view with your own. (Or, you could specify a custom widget configured to use your modified view.)

(I tend to recommend against monkey-patching unless there is no other alternative - which isn’t the case here.)

Thanks for the suggestion!

When you say there’s nothing stopping you from replacing the admin-supplied view with your own, do you have in mind a specific way how to do it (even a code snippet perhaps)? The AutocompleteJsonView view is registered behind the scenes with the default site get_urls() (that I wouldn’t want to mess with), see here, and I’m not sure what the right hook point is.

Briefly, urls are searched in the order that they’re defined.

If you know that /admin/abc/def/<str:param>/ is the url you want to “capture”, you can add that entry to your urls.py file. Your view will process that url, not the one supplied by the admin. (You only need to ensure that your definition for that url is before the generic path('admin/', admin.site.urls) entry.)

Thanks again for your thoughts!

I would argue that ModelAdmin.get_urls() is a better fit in this case than urls.py as the customization is encapsulated in the context of that particular ModelAdmin.

Something in the lines of:

class MyModelAdmin(admin.ModelAdmin):
    def get_urls(self):
        return [
            path('autocomplete/', CustomAutocompleteJsonView.as_view(admin_site=self.admin_site))
            if url.pattern.match('autocomplete/')
            else url for url in super().get_urls()
        ]

What do you think?

Either one is fine. Putting it in the global URLs makes it more visible and explicit if you have other forms on other pages where you wish to use that same facility, get_urls makes it appear that this is something that should only apply to the admin.

(Note: I don’t think that’s quite the right construct for this situation - the example shows appending the standard urls to the custom url - you don’t have a url being requested at the time this method is called, but I get the idea.)

Anyway, the original point is that it is easy to intercept the url being requested to replace a system-defined view with your own.

Right, cheers!

The project still uses Django version 2.2 and I noticed that the AutocompleteJsonView.get() method that I need to override differs quite a lot from version 3 and has quite different assumptions, see the 2.2 version here and latest main here.

What do you think, does this use case justify suggesting adding a simple extension point to get() to Django developers to avoid the maintenance burden?

Just moving the lines that construct the results inside JsonResponse to a separate method would help a lot, so instead of

        return JsonResponse({
            'results': [
                {'id': str(getattr(obj, to_field_name)), 'text': str(obj)}
                for obj in context['object_list']
            ],
            'pagination': {'more': context['page_obj'].has_next()},
        })

there would be

        return JsonResponse({
            'results': [
                obj_to_dict(obj, to_field_name) for obj in context['object_list']
            ],
            'pagination': {'more': context['page_obj'].has_next()},
        })

where obj_to_dict() contains code from the original snippet that would be now easy to override:

def obj_to_dict(obj, to_field_name):
    return {'id': str(getattr(obj, to_field_name)), 'text': str(obj)}

I’m not sure - and probably am far from being the best person to offer an opinion on that point.

If you wanted to pursue it, the best way would be to open an issue in the ticket tracker along with the suggested patch. (I don’t know if you’ve done this before or not - if not, the docs for doing this start here: Writing code | Django documentation | Django. There are also many people here willing to help you with the process if you have questions.)

Thanks for the pointers, I’ll give it a try! I have contributed to Django before, let’s see how it goes this time.

1 Like

Just a note that I created the ticket: #32993 (Refactor AutocompleteJsonView to support extra fields in autocomplete response) – Django.

1 Like

The ticket was accepted, thanks again for your support!

For completeness, here’s how it will work once the pull request is merged:

admin.py in customers app:

@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
    def get_urls(self):
        return [
            path('autocomplete/', CustomerAutocompleteJsonView.as_view(model_admin=self), name='customers_customer_autocomplete')
            if url.pattern.match('autocomplete/')
            else url for url in super().get_urls()
        ]

class CustomerAutocompleteJsonView(AutocompleteJsonView):
    def serialize_result(self, obj, to_field_name):
        return super.serialize_result(obj, to_field_name) | {'notes': obj.notes}

notes.js included in admin media in sales orders app:

  $('#id_customer').on('select2:select', function(evt) {
    var noteText = evt.params.data.notes
    addNoteFromSelectedItem(noteText)
  })
1 Like