Django Inline for ManyToMany generate duplicate queries
I've assembled a generic solution based on @makaveli's answer that doesn't seem to have problem mentioned in the comments:
class CachingModelChoicesFormSet(forms.BaseInlineFormSet): """ Used to avoid duplicate DB queries by caching choices and passing them all the forms. To be used in conjunction with `CachingModelChoicesForm`. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) sample_form = self._construct_form(0) self.cached_choices = {} try: model_choice_fields = sample_form.model_choice_fields except AttributeError: pass else: for field_name in model_choice_fields: if field_name in sample_form.fields and not isinstance( sample_form.fields[field_name].widget, forms.HiddenInput): self.cached_choices[field_name] = [c for c in sample_form.fields[field_name].choices] def get_form_kwargs(self, index): kwargs = super().get_form_kwargs(index) kwargs['cached_choices'] = self.cached_choices return kwargsclass CachingModelChoicesForm(forms.ModelForm): """ Gets cached choices from `CachingModelChoicesFormSet` and uses them in model choice fields in order to reduce number of DB queries when used in admin inlines. """ @property def model_choice_fields(self): return [fn for fn, f in self.fields.items() if isinstance(f, (forms.ModelChoiceField, forms.ModelMultipleChoiceField,))] def __init__(self, *args, **kwargs): cached_choices = kwargs.pop('cached_choices', {}) super().__init__(*args, **kwargs) for field_name, choices in cached_choices.items(): if choices is not None and field_name in self.fields: self.fields[field_name].choices = choices
All you'll need to do is subclass your model from CachingModelChoicesForm and use CachingModelChoicesFormSet in your inline class:
class ArrangementInlineForm(CachingModelChoicesForm): class Meta: model = Arrangement exclude = ()class ArrangementInline(admin.TabularInline): model = Arrangement extra = 50 form = ArrangementInlineForm formset = CachingModelChoicesFormSet
EDIT 2020:
Check out the answer by @isobolev below who's taken this answer and improved on it to make it more generic. :)
This is pretty much normal behaviour in Django - it doesn't do the optimization for you, but it gives you decent tools to do it yourself. And don't sweat it, 100 queries isn't really a big problem (I've seen 16k queries on one page) that needs fixing right away. But if your amounts of data are gonna increase rapidly, then it's wise to deal with it of course.
The main weapons you'll be armed with are queryset methods select_related()
and prefetch_related()
. There's really no point of going too deeply into them since they're very well documented here, but just a general pointer:
use
select_related()
when the object you're querying has only one related object (FK or one2one)use
prefetch_related()
when the object you're querying has multiple related objects (the other end of FK or M2M)
And how to use them in Django admin, you ask? Elementary, my dear Watson. Override the admin page method get_queryset(self, request)
so it would look sth like this:
from django.contrib import adminclass SomeRandomAdmin(admin.ModelAdmin): def get_queryset(self, request): return super().get_queryset(request).select_related('field1', 'field2').prefetch_related('field3')
EDIT: Having read your comment, I realise that my initial interpretation of your question was absolutely wrong. I do have multiple solutions for your problem as well and here goes that:
The simple one that I use most of the time and recommend: just replace the Django default select widgets with
raw_id_field
widgets and no queries are made. Just setraw_id_fields = ('setting', 'displayed_group')
in the inline admin and be done for.But, if you don't want to get rid of the select boxes, I can give some half-hacky code that does the trick, but is rather lengthy and not very pretty. The idea is to override the formset that creates the forms and specify choices for these fields in the formset so that they're only queried once from the database.
Here it goes:
from django import formsfrom django.contrib import adminfrom app.models import Arrangement, Machine, Setting, DisplayedGroupclass ChoicesFormSet(forms.BaseInlineFormSet): setting_choices = list(Setting.objects.values_list('id', 'name')) displayed_group_choices = list(DisplayedGroup.objects.values_list('id', 'name')) def _construct_form(self, i, **kwargs): kwargs['setting_choices'] = self.setting_choices kwargs['displayed_group_choices'] = self.displayed_group_choices return super()._construct_form(i, **kwargs)class ArrangementInlineForm(forms.ModelForm): class Meta: model = Arrangement exclude = () def __init__(self, *args, **kwargs): setting_choices = kwargs.pop('setting_choices', [((), ())]) displayed_group_choices = kwargs.pop('displayed_group_choices', [((), ())]) super().__init__(*args, **kwargs) # This ensures that you can still save the form without setting all 50 (see extra value) inline values. # When you save, the field value is checked against the "initial" value # of a field and you only get a validation error if you've changed any of the initial values. self.fields['setting'].choices = [('-', '---')] + setting_choices self.fields['setting'].initial = self.fields['setting'].choices[0][0] self.fields['setting'].empty_values = (self.fields['setting'].choices[0][0],) self.fields['displayed_group'].choices = displayed_group_choices self.fields['displayed_group'].initial = self.fields['displayed_group'].choices[0][0]class ArrangementInline(admin.TabularInline): model = Arrangement extra = 50 form = ArrangementInlineForm formset = ChoicesFormSet def get_queryset(self, request): return super().get_queryset(request).select_related('setting')class MachineAdmin(admin.ModelAdmin): inlines = (ArrangementInline,)admin.site.register(Machine, MachineAdmin)
If you find something that could be improved or have any questions, let me know.
Nowadays, (kudos to that question), BaseFormset receives a form_kwargs
attribute.
The ChoicesFormSet
code in the accepted answer could be slightly modified as such:
class ChoicesFormSet(forms.BaseInlineFormSet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) setting_choices = list(Setting.objects.values_list('id', 'name')) displayed_group_choices = list(DisplayedGroup.objects.values_list('id', 'name')) self.form_kwargs['setting_choices'] = self.setting_choices self.form_kwargs['displayed_group_choices'] = self.displayed_group_choices
The rest of the code stays intact, as desscribed in the accepted answer:
class ArrangementInlineForm(forms.ModelForm): class Meta: model = Arrangement exclude = () def __init__(self, *args, **kwargs): setting_choices = kwargs.pop('setting_choices', [((), ())]) displayed_group_choices = kwargs.pop('displayed_group_choices', [((), ())]) super().__init__(*args, **kwargs) # This ensures that you can still save the form without setting all 50 (see extra value) inline values. # When you save, the field value is checked against the "initial" value # of a field and you only get a validation error if you've changed any of the initial values. self.fields['setting'].choices = [('-', '---')] + setting_choices self.fields['setting'].initial = self.fields['setting'].choices[0][0] self.fields['setting'].empty_values = (self.fields['setting'].choices[0][0],) self.fields['displayed_group'].choices = displayed_group_choices self.fields['displayed_group'].initial = self.fields['displayed_group'].choices[0][0]class ArrangementInline(admin.TabularInline): model = Arrangement extra = 50 form = ArrangementInlineForm formset = ChoicesFormSet def get_queryset(self, request): return super().get_queryset(request).select_related('setting')class MachineAdmin(admin.ModelAdmin): inlines = (ArrangementInline,)admin.site.register(Machine, MachineAdmin)