8

I'm trying to set a custom label for all of the items of a type in autocomplete_fields.

Until now, for a drop-down list, one would use

...
class CustomDisplay(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        return "Some custom text: {}".format(obj.name)
...
somethings = CustomDisplay(queryset=Something.object.all())
...

but using this with autocomplete_fields = (somethings,) will result in autocomplete canceling and showing me a dropdown with the custom text.

Dorinel Panaite
  • 492
  • 1
  • 6
  • 15

2 Answers2

11

The reason the field shows a normal select widget is that, when you define your custom field, you don't set the widget as an AutocompleteSelect.

In the ModelAdmin class where you specify your autocomplete_fields, import your CustomDisplay and AutocompleteSelect and add the following method:

from django.contrib.admin.widgets import AutocompleteSelect


class YourModelAdmin(admin.ModelAdmin):
    autocomplete_fields = ['something']

    ...

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        db = kwargs.get('using')

        if db_field.name == 'something':
            return CustomDisplay(queryset=Something.object.all(), widget=AutocompleteSelect(db_field.remote_field, self.admin_site, using=db))
        return super().formfield_for_foreignkey(db_field, request, **kwargs)

That will only display the custom text when you view an existing instance. When you view the autocomplete drop down, and select an entry, the label is not generated from label_from_instance(), but from a straightforward str() call inside AutocompleteJsonView.

So assuming you only want to change the label in the autocomplete widget (to change the label across the board you would obviously just change the model __str()__ method), you also need to create a custom class in admin.py that modifies the get() method in AutocompleteJsonView:

from django.contrib.admin.options import AutocompleteJsonView
from django.http import Http404, JsonResponse


class CustomAutocompleteJsonView(AutocompleteJsonView):
    def get(self, request, *args, **kwargs):
        if not self.model_admin.get_search_fields(request):
            raise Http404(
                '%s must have search_fields for the autocomplete_view.' %
                type(self.model_admin).__name__
            )
        if not self.has_perm(request):
            return JsonResponse({'error': '403 Forbidden'}, status=403)

        self.term = request.GET.get('term', '')
        self.paginator_class = self.model_admin.paginator
        self.object_list = self.get_queryset()
        context = self.get_context_data()

        # Replace this with the code below.
        #
        # return JsonResponse({
        #     'results': [
        #         {'id': str(obj.pk), 'text': str(obj)}
        #         for obj in context['object_list']
        #     ],
        #     'pagination': {'more': context['page_obj'].has_next()},
        # })

        return JsonResponse({
            'results': [
                {'id': str(obj.pk), 'text': 'Some custom text: {}'.format(obj.name)}
                for obj in context['object_list']
            ],
            'pagination': {'more': context['page_obj'].has_next()},
    })

Now set the autocomplete_view on the ModelAdmin class that the autocomplete is displaying results for (not the ModelAdmin class where you specify autocomplete_fields):

def autocomplete_view(self, request):
    return CustomAutocompleteJsonView.as_view(model_admin=self)(request)

So if you have a ModelAdmin class called YourModelAdmin with autocomplete_fields = ['something'], you would set the autocomplete_view for the corresponding ModelAdmin class for your Something model.

Update for Django 4.2:

from django.contrib.admin.sites import AdminSite
from django.contrib.admin.widgets import AutocompleteSelect
from django.contrib.admin.views.autocomplete import AutocompleteJsonView
from django.core.exceptions import PermissionDenied


class CustomAdminSite(AdminSite):
    # This will change the view for all autocompletes.
    # You can handle specific cases within the autocomplete get() method.
    def autocomplete_view(self, request):
        return CustomAutocompleteJsonView.as_view(admin_site=self)(request)


admin_site = CustomAdminSite()


class CustomAutocompleteJsonView(AutocompleteJsonView):
    def get(self, request, *args, **kwargs):
        (
            self.term,
            self.model_admin,
            self.source_field,
            to_field_name,
        ) = self.process_request(request)

        if not self.has_perm(request):
            raise PermissionDenied

        self.object_list = self.get_queryset()
        context = self.get_context_data()
        if type(self.model_admin) == SomethingModelAdmin:
            return JsonResponse(
                {
                    "results": [
                        {'id': str(obj.pk), 'text': 'Some custom text: {}'.format(obj.name)}
                        for obj in context['object_list']
                    ],
                    "pagination": {"more": context["page_obj"].has_next()},
                }
            )
        else:
            return super().get(request, *args, **kwargs)


class YourModelAdmin(admin.ModelAdmin):
    autocomplete_fields = ['something']

    ...

    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        db = kwargs.get('using')

        if db_field.name == 'something':
            return CustomDisplay(queryset=Something.objects.all(),
                                           widget=AutocompleteSelect(db_field,
                                                                     self.admin_site,
                                                                     using=db))
        return super().formfield_for_foreignkey(db_field, request, **kwargs)
alstr
  • 1,358
  • 18
  • 34
  • Wow! I would have never figured this out – HenryM Feb 26 '21 at 07:50
  • 4
    Unfortunately, Django 3.2 moved `autocomplete_view` off of `ModelAdmin`, and I haven't yet been able to figure out how to adapt the (great-looking!) solution above. I'll post here if I crack it but wanted to warn other readers that this solution may no longer work as expected. – rossettistone Dec 10 '21 at 23:06
  • 1
    @rossettistone Things have changed around a bit since my original answer. I've provided an update based on Django 4.2. – alstr May 30 '23 at 12:54
0

This is what ended up working for me:

On the parent admin use autocomplete_fields:

class ParentAdmin(models.ModelAdmin):
    autocomplete_fields = (
       "sub_account_stuff",
       "other_sub_items",
    )

The autocomplete_fields use search_fields on the sub admin models, HOWEVER, you can override with get_search_results so I created a mixin like so, where parsing the request referer url can resolve the parent:

class AutoCompleteSearchParentIdsMixin:
    def get_parent_object_from_auto_complete_search(self, request):
        from urllib.parse import urlparse
        path_info = urlparse(request.headers.get('Referer')).path
        resolved = resolve(path_info)
        if resolved.kwargs:
            return MyParentModel.objects.get(pk=resolved.kwargs["object_id"])
        return None

    def get_search_results(self, request, queryset, search_term):
        parent = self.get_parent_object_from_auto_complete_search(request)
        if parent:
            queryset = queryset.filter(
                parent_ids__contained_by=parent.ids
            )
            if search_term.isnumeric():
                queryset = queryset.filter(Q(id=int(search_term)) | Q(parent_id=int(search_term)))
            else:
                queryset = queryset.filter(Q(payload__Name__icontains=search_term))
        else:
            queryset = queryset.none()
        return queryset.distinct(), False

And mixed in to override get_search_results like so:

class SubAccountAdmin(AutoCompleteSearchParentIdsMixin, admin.ModelAdmin):
    ...

class OtherSubItemsAdmin(AutoCompleteSearchParentIdsMixin, admin.ModelAdmin):
    ...

And to change the way it is displayed, i added a custom __str__ to the models.

class OtherSubItemsModel(models.Model):
   ...
   def __str__(self, obj):
      return f"<OtherSubItem id={self.id} name={self.payload.get('Name')}>"

Referenced:

jmunsch
  • 22,771
  • 11
  • 93
  • 114