7

I have a situation where I wish to utilize Django's autocomplete admin widget, that respects a referencing models field limitation.

For example I have the following Collection model that has the attribute kind with specified choices.

class Collection(models.Model):
    ...
    COLLECTION_KINDS = (
        ('personal', 'Personal'),
        ('collaborative', 'Collaborative'),
    )

    name = models.CharField()
    kind = models.CharField(choices=COLLECTION_KINDS)
    ...

Another model ScheduledCollection references Collection with a ForeignKey field that implements limit_choices_to option. The purpose of this model is to associate meta data to a Collection for a specific use case.

class ScheduledCollection(models.Model):
    ...
    collection = models.ForeignKey(Collection, limit_choices_to={'kind': 'collaborative'})

    start_date = models.DateField()
    end_date = models.DateField()
    ...

Both models are registered with a ModelAdmin. The Collection model implements search_fields.

@register(models.Collection)
class CollectionAdmin(ModelAdmin):
    ...
    search_fields = ['name']
    ...

The ScheduledCollection model implements autocomplete_fields

@register(models.ScheduledCollection)
class ScheduledCollectionAdmin(ModelAdmin):
    ...
    autocomplete_fields = ['collection']
    ...

This works but not entirely as expected. The autocomplete retrieves results from a view generated by the Collection model. The limit_choices_to do not filter the results and are only enforced upon save.

It has been suggested to implement get_search_results or get_queryset on the CollectionAdmin model. I was able to do this and filter the results. However, this changes Collection search results across the board. I am unaware of how to attain more context within get_search_results or get_queryset to conditionally filter the results based upon a relationship.

In my case I would like to have several choices for Collection and several meta models with different limit_choices_to options and have the autocomplete feature respect these restrictions.

I don't expect this to work automagically and maybe this should be a feature request. At this point I am at a loss how to filter the results of a autocomplete with the respect to a choice limitation (or any condition).

Without using autocomplete_fields the Django admin's default <select> widget filters the results.

John Baker
  • 71
  • 1
  • 4

5 Answers5

9

Triggering off the http referer was ugly so I made a better version: subclass the AutocompleteSelect and send extra query parameters to allow get_search_results to lookup the correct limit_choices_to automagically. Simply include this mixin in your ModelAdmin (for both source and target models). As a bonus it also adds a delay to the ajax requests so you don't spam the server as you type in the filter, makes the select wider and sets the search_fields attribute (to 'translations__name' which is correct for my system, customise for yours or omit and set individually on the ModelAdmins as before):

from django.contrib.admin import widgets
from django.utils.http import urlencode
from django.contrib.admin.options import ModelAdmin

class AutocompleteSelect(widgets.AutocompleteSelect):
    """
    Improved version of django's autocomplete select that sends an extra query parameter with the model and field name
    it is editing, allowing the search function to apply the appropriate filter.
    Also wider by default, and adds a debounce to the ajax requests
    """

    def __init__(self, rel, admin_site, attrs=None, choices=(), using=None, for_field=None):
        super().__init__(rel, admin_site, attrs=attrs, choices=choices, using=using)
        self.for_field = for_field

    def build_attrs(self, base_attrs, extra_attrs=None):
        attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs)
        attrs.update({
            'data-ajax--delay': 250,
            'style': 'width: 50em;'
        })
        return attrs

    def get_url(self):
        url = super().get_url()
        url += '?' + urlencode({
            'app_label': self.for_field.model._meta.app_label,
            'model_name': self.for_field.model._meta.model_name,
            'field_name': self.for_field.name
        })
        return url


class UseAutocompleteSelectMixin():
    """
    To avoid ForeignKey fields to Event (such as on ReportColumn) in admin from pre-loading all events
    and thus being really slow, we turn them into autocomplete fields which load the events based on search text
    via an ajax call that goes through this method.
    Problem is this ignores the limit_choices_to of the original field as this ajax is a general 'search events'
    without knowing the context of what field it is populating. Someone else has exact same problem:
    https://stackoverflow.com/questions/55344987/how-to-filter-modeladmin-autocomplete-fields-results-with-the-context-of-limit-c
    So fix this by adding extra query parameters on the autocomplete request,
    and use these on the target ModelAdmin to lookup the correct limit_choices_to and filter with it.
    """

    # Overrides django.contrib.admin.options.ModelAdmin#formfield_for_foreignkey
    # Is identical except in case db_field.name is in autocomplete fields it constructs our improved AutocompleteSelect
    # instead of django's and passes it extra for_field parameter
    def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name in self.get_autocomplete_fields(request):
            db = kwargs.get('using')
            kwargs['widget'] = AutocompleteSelect(db_field.remote_field, self.admin_site, using=db, for_field=db_field)
            if 'queryset' not in kwargs:
                queryset = self.get_field_queryset(db, db_field, request)
                if queryset is not None:
                    kwargs['queryset'] = queryset

            return db_field.formfield(**kwargs)

        return super().formfield_for_foreignkey(db_field, request, **kwargs)

    # In principle we could add this override in a different mixin as adding the formfield override above is needed on
    # the source ModelAdmin, and this is needed on the target ModelAdmin, but there's do damage adding everywhere so combine them.
    def get_search_results(self, request, queryset, search_term):
        if 'app_label' in request.GET and 'model_name' in request.GET and 'field_name' in request.GET:
            from django.apps import apps
            model_class = apps.get_model(request.GET['app_label'], request.GET['model_name'])
            limit_choices_to = model_class._meta.get_field(request.GET['field_name']).get_limit_choices_to()
            if limit_choices_to:
                queryset = queryset.filter(**limit_choices_to)
        return super().get_search_results(request, queryset, search_term)

    search_fields = ['translations__name']

pasevin
  • 1,436
  • 1
  • 12
  • 14
Uberdude
  • 453
  • 3
  • 5
  • Excuse me but where to put this script? Can you specify? Is it in admin.py or models.py? – Vieri_Wijaya Feb 24 '21 at 03:09
  • 1
    Using the main question example, copy the above scripts to the same file as ScheduledCollectionAdmin and CollectionAdmin. Then add UseAutocompleteSelectMixin to the admin classes: ... class ScheduledCollectionAdmin(UseAutocompleteSelectMixin, ModelAdmin): .... class CollectionAdmin(UseAutocompleteSelectMixin, ModelAdmin): ... – Frederico Oliveira Feb 24 '21 at 18:40
  • 1
    Django 3.2 is now respecting limit_choice_to for search fields: https://docs.djangoproject.com/en/3.2/releases/3.2/ – Frederico Oliveira Jun 09 '21 at 16:30
2

My solution is to wrap get_url method on the widget.

Create a util method as shown below.

def wrap_get_url(original_get_url, extra_url_params: QueryDict) -> Callable:
    def get_url_with_extra_url_params(*args, **kwargs) -> str:
        url: str = original_get_url(*args, **kwargs)
        scheme, netloc, url, params, query, fragment = tuple(urlparse(url))
        query = QueryDict(query_string=query, mutable=True)
        query.update(extra_url_params)
        url_parts = (scheme, netloc, url, params, query.urlencode(), fragment)
        return urlunparse(url_parts)

    return get_url_with_extra_url_params

Create a custom form for your model admin.

class ExampleModelAdminForm(forms.ModelForm):
    class Meta:
        model = ExampleModel
        exclude: List[str] = []

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        instance = getattr(self, "instance", None)
        # Check for RelatedWidgetWrapper
        if widget := getattr(self.fields["target_model"].widget, "widget", None):
           query = QueryDict(mutable=True)
           query["example_model_id"] = instance.pk
           widget.get_url = wrap_get_url(
                        original_get_url=widget.get_url,
                        extra_url_params=query,
           )

class ExampleModelAdmin(admin.ModelAdmin):
    form = forms.ExampleModelAdminForm
    autocomplete_fields = ("target_model",)

On the target model admin.

class TargetModelAdmin(admin.ModelAdmin):
    search_fields = ("name", ) # Define your search fields

    def get_search_results(self, request, queryset, search_term) -> tuple[QuerySet, bool]:
        qs: QuerySet
        duplicate: bool
        qs, duplicate = super(TargetModelAdmin, self).get_search_results(request, queryset, search_term)
        # Get Example model id from previous admin page in order to filter the queryset
        if example_model_id := request.GET.get("example_account_id", None):
            example_model: ExampleModel = ExampleModel.objects.get(
                id=example_model_id
            )
            qs = qs.filter(field=example_model.field) # Filter your qs here
        return qs, duplicate
  • 1
    You are a great man. I was try find solution for few days. Your solution work exactly as you say, and work like a charm. I think, filtering for m2m select2 widget must be described in Django's documentation. Thank you very much again! – swasher Jun 09 '23 at 07:02
1

I had the exact same problem. It's a bit hacky, but here's my solution:

  1. Override get_search_results of the ModelAdmin you are searching for and want to filter
  2. Use the request referer header to get the magical context you need to apply the appropriate filter based on the source of the relationship
  3. Grab the limit_choices_to from the appropriate ForeignKey's _meta
  4. Pre-filter the queryset and then pass to super method.

So for your models:

@register(models.Collection)
class CollectionAdmin(ModelAdmin):
    ...
    search_fields = ['name']

    def get_search_results(self, request, queryset, search_term):
        if '<app_name>/scheduledcollection/' in request.META.get('HTTP_REFERER', ''):
            limit_choices_to = ScheduledCollection._meta.get_field('collection').get_limit_choices_to()
            queryset = queryset.filter(**limit_choices_to)
        return super().get_search_results(request, queryset, search_term)

A shortcoming of this approach is the only context we have is the model being edited in admin, rather than which field of the model, so if your ScheduledCollection model has 2 collection autocomplete fields (say personal_collection and collaborative_collection) with different limit_choices_to we can't infer this from the referer header and treat them differently. Also inline admins will have the referer url based on the parent thing they are an inline for, rather than reflecting their own model. But it works in the basic cases.

Hopefully a new version of Django will have a cleaner solution, such as the autocomplete select widget sending an extra query parameter with the model and field name it is editing so that get_search_results can accurately look up the required filters instead of (potentially inaccurately) inferring from the referer header.

Uberdude
  • 453
  • 3
  • 5
1

Here is another solution to get only a subset of choices in the auto-complete field. This solution does not change the default behavior for the main model (Collection), so you can still have other views using autocomplete with the full set in your app.

Here is how it works:

Proxy model for Collection with manager

Create a proxy model to represent a subset of Collection, e.g. CollaborativeCollection to represent collections that are of kind "collaborative". You will also need a manager to restrict the initial queryset of your proxy model to the intended subset.

class CollaborativeCollectionManager(models.Manager):
    def get_queryset(self):
        return (
            super()
            .get_queryset()
            .filter(kind="collaborative")
        )


class CollaborativeCollection(models.Model):
    class Meta:
        proxy = True

    objects = CollaborativeCollectionManager()

Updating foreign key to use proxy model

Next update the foreign key in ScheduledCollection to use the proxy model instead. Note that you can remove the limit_choices_to feature if you don't need it for anything else.

class ScheduledCollection(models.Model):
    ...
    collection = models.ForeignKey(CollaborativeCollection)

    start_date = models.DateField()
    end_date = models.DateField()
    ...

Define admin model for Proxy

Finally define the admin model for the proxy.

@admin.register(CollaborativeCollection)
class CollaborativeCollectionAdmin(admin.ModelAdmin): 
    search_fields = ["name"]

Note that instead of the manager, you could also define a custom get_search_results() in the admin model. However, I found that the manager approach appears to be more performant. And it also is conceptually more sounds, since with that all queries to CollaborativeCollection will only return collaborative collections.

Erik Kalkoken
  • 30,467
  • 8
  • 79
  • 114
0

With Django 3.2, the solution proposed by @Uberdude does not work anymore because AutocompleteSelect's constructor now takes a field rather than a relation.

Here is the updated code needed for the formfield_for_foreignkey method:

def formfield_for_foreignkey(self, db_field, request, **kwargs):
        if db_field.name in self.get_autocomplete_fields(request) or\
                db_field.name in self.get_autocomplete_cb_fields(request):
            db = kwargs.get('using')
            if db_field.name in self.get_autocomplete_cb_fields(request):
                kwargs['widget'] = AutocompleteSelectCb(
                    db_field, self.admin_site, using=db, for_field=db_field)
            else:
                kwargs['widget'] = AutocompleteSelect(
                    db_field, self.admin_site, using=db, for_field=db_field)
            if 'queryset' not in kwargs:
                queryset = self.get_field_queryset(db, db_field, request)
                if queryset is not None:
                    kwargs['queryset'] = queryset

            return db_field.formfield(**kwargs)

        return super().formfield_for_foreignkey(db_field, request, **kwargs)
jphilip
  • 136
  • 2
  • 9