Search code examples
djangodjango-modelsforeign-keysentity-relationship

Django admin - autocomplete(_field) without foreignkey or manytomany relationship


I was wondering whether there is a function which lets me implement an autocomplete_field without having this variable be linked to another relation via a foreign key.

I.e. I have the models Aaa, Bbb, & Ccc. Bbb & Ccc are related using a foreign key, while Aaa is related to the other two models through even other models. Now I want on the admin side a specific field Ccc, which consists of a field in Aaa, to be autocomplete with the values from Aaa (or at least a suggestion feature so that mistakes are minimized). However, Ccc and Aaa are not directly related; thus, I find it invalid to just assign this item a foreign key. Any suggestions on how to resolve this?

As you can figure from my question, I am pretty new to django and would be very grateful for some help here.


Solution

  • You can subclass these and do some monkey patching to reuse Django's autocomplete logic:

    1. django.contrib.admin.widgets.AutocompleteSelect
    2. django.contrib.admin.ModelAdmin
    3. django.contrib.admin.views.autocomplete.AutocompleteJsonView
    class MyAutocompleteSelectWidget(widgets.AutocompleteSelect):
        url_name = 'my_autocomplete'
    
        def get_url(self):
            return reverse(self.url_name)
    
        def optgroups(self, name, value, attr=None):
            # Patch self.choices for non-ModelChoiceField.widget
            to_field_name = getattr(self.field.remote_field, 'field_name')
            self.choices = SimpleNamespace(
                field=SimpleNamespace(empty_values=(), label_from_instance=lambda obj: getattr(obj, to_field_name)),
                queryset=SimpleNamespace(using=lambda _: SimpleNamespace(filter=lambda **_: [SimpleNamespace(**{to_field_name: v}) for v in value])))
            return super().optgroups(name, value, attr=attr)
    
    class MyAutocompleteModelAdmin(admin.ModelAdmin):
        my_autocomplete_fields = {}
    
        def __init__(self, model, admin_site):
            super().__init__(model, admin_site)
            # Patch remote_field for AutocompleteJsonView.process_request
            for field_name, deferred_remote_field in self.my_autocomplete_fields.items():
                remote_field = deferred_remote_field.field
                self.model._meta.get_field(field_name).remote_field = SimpleNamespace(field_name=remote_field.attname, model=remote_field.model)
    
        def formfield_for_dbfield(self, db_field, request, **kwargs):
            if 'widget' not in kwargs:
                if db_field.name in self.my_autocomplete_fields:
                    kwargs['widget'] = MyAutocompleteSelectWidget(db_field, self.admin_site)
            return super().formfield_for_dbfield(db_field, request, **kwargs)
    
        def to_field_allowed(self, request, to_field):
            # Allow search fields that are not referenced by foreign key fields
            if to_field in self.search_fields:
                return True
            return super().to_field_allowed(request, to_field)
    
    class MyAutocompleteJsonView(autocomplete.AutocompleteJsonView):
    
        def get_queryset(self):
            # Patch get_limit_choices_to for non-foreign key field
            self.source_field.get_limit_choices_to = lambda: {}
            return super().get_queryset()
    
        def process_request(self, request):
            term, model_admin, source_field, to_field_name = super().process_request(request)
            # Store to_field_name for use in get_context_data
            self.to_field_name = to_field_name
            return term, model_admin, source_field, to_field_name
    
        def get_context_data(self, *, object_list=None, **kwargs):
            context_data = super().get_context_data(object_list=object_list, **kwargs)
            # Patch __str__ to use to_field_name for `str(obj)` in AutocompleteJsonView.get
            for obj in context_data['object_list']:
                obj_type = type(obj)
                new_obj_type = type(obj_type.__name__, (obj_type,), {'__str__': lambda _self: getattr(_self, self.to_field_name), '__module__': obj_type.__module__})
                obj.__class__ = new_obj_type
            return context_data
    

    Usage:

    @admin.register(Aaa)
    class AaaAdmin(MyAutocompleteModelAdmin):
        search_fields = ('a_field',)
    
    
    @admin.register(Ccc)
    class CccAdmin(MyAutocompleteModelAdmin):
        my_autocomplete_fields = {
            'a_specific_field': Aaa.a_field,
        }
    
    path('my_autocomplete', MyAutocompleteJsonView.as_view(admin_site=admin.site), name='my_autocomplete')