Limit foreign key choices in select in an inline form in admin
Asked Answered
M

11

87

The logic is of the model is:

  • A Building has many Rooms
  • A Room may be inside another Room (a closet, for instance--ForeignKey on 'self')
  • A Room can only be inside another Room in the same building (this is the tricky part)

Here's the code I have:

#spaces/models.py
from django.db import models    

class Building(models.Model):
    name=models.CharField(max_length=32)
    def __unicode__(self):
        return self.name

class Room(models.Model):
    number=models.CharField(max_length=8)
    building=models.ForeignKey(Building)
    inside_room=models.ForeignKey('self',blank=True,null=True)
    def __unicode__(self):
        return self.number

and:

#spaces/admin.py
from ex.spaces.models import Building, Room
from django.contrib import admin

class RoomAdmin(admin.ModelAdmin):
    pass

class RoomInline(admin.TabularInline):
    model = Room
    extra = 2

class BuildingAdmin(admin.ModelAdmin):
    inlines=[RoomInline]

admin.site.register(Building, BuildingAdmin)
admin.site.register(Room)

The inline will display only rooms in the current building (which is what I want). The problem, though, is that for the inside_room drop down, it displays all of the rooms in the Rooms table (including those in other buildings).

In the inline of rooms, I need to limit the inside_room choices to only rooms which are in the current building (the building record currently being altered by the main BuildingAdmin form).

I can't figure out a way to do it with either a limit_choices_to in the model, nor can I figure out how exactly to override the admin's inline formset properly (I feel like I should be somehow create a custom inline form, pass the building_id of the main form to the custom inline, then limit the queryset for the field's choices based on that--but I just can't wrap my head around how to do it).

Maybe this is too complex for the admin site, but it seems like something that would be generally useful...

Massengale answered 1/12, 2009 at 6:1 Comment(0)
S
117

Used request instance as temporary container for obj. Overrided Inline method formfield_for_foreignkey to modify queryset. This works at least on django 1.2.3.

class RoomInline(admin.TabularInline):

    model = Room

    def formfield_for_foreignkey(self, db_field, request=None, **kwargs):

        field = super(RoomInline, self).formfield_for_foreignkey(db_field, request, **kwargs)

        if db_field.name == 'inside_room':
            if request._obj_ is not None:
                field.queryset = field.queryset.filter(building__exact = request._obj_)  
            else:
                field.queryset = field.queryset.none()

        return field



class BuildingAdmin(admin.ModelAdmin):

    inlines = (RoomInline,)

    def get_form(self, request, obj=None, **kwargs):
        # just save obj reference for future processing in Inline
        request._obj_ = obj
        return super(BuildingAdmin, self).get_form(request, obj, **kwargs)
Slipsheet answered 21/11, 2010 at 2:45 Comment(5)
This right here saved me a lot of hassle. I needed to filter choices, but by a session variable. This answer let me do it with 5 lines of code. Thank you.Shem
Thanks a million! An alternative is to assign kwargs['queryset'] before calling super as per docs: docs.djangoproject.com/en/dev/ref/contrib/admin/…Antique
This code saved me TONS of time as well. Thanks a lot for posting thisGifford
THIS! I was looking for something like this for my problem. Took me days to find this.Autism
But user still can select wrong Room in popup. See https://mcmap.net/q/237690/-limit-foreign-key-choices-in-select-in-an-inline-form-in-admin for solutionHopehopeful
F
21

There is limit_choices_to ForeignKey option that allows to limit the available admin choices for the object

Fulgurant answered 12/7, 2012 at 6:46 Comment(2)
This doesn't help as the query that runs in the limit_choices_to has no reference to the "parent class". I.e, if a model A has a foreign-key to B, and also to C, and C has a foreign-key to B, and we want to ensure an A only refers to a C that refers to the same B as A does, the query needs to know about A->B, which it doesn't.Strohben
It can be usefull with top answer combination, see https://mcmap.net/q/237690/-limit-foreign-key-choices-in-select-in-an-inline-form-in-adminHopehopeful
C
18

After reading through this post and experimenting a lot I think I have found a rather definitive answer to this question. As this is a design pattern that is ofter used I have written a Mixin for the Django admin to make use of it.

(Dynamically) limiting the queryset for ForeignKey fields is now as simple as subclassing LimitedAdminInlineMixin and defining a get_filters(obj) method to return the relevant filters. Alternateively, a filters property can be set on the admin if dynamic filtering is not required.

Example usage:

class MyInline(LimitedAdminInlineMixin, admin.TabularInline):
    def get_filters(self, obj):
        return (('<field_name>', dict(<filters>)),)

Here, <field_name> is the name of the FK field to be filtered and <filters> is a list of parameters as you would normally specify them in the filter() method of querysets.

Coley answered 15/2, 2011 at 20:14 Comment(1)
Thanks, works great! Much cleaner. (And btw, you left some logging statements in your code that don't go anywhere)Cowie
S
8

You can create a couple of custom classes that will then pass along a reference to the parent instance to the form.

from django.forms.models import BaseInlineFormSet
from django.forms import ModelForm

class ParentInstInlineFormSet(BaseInlineFormSet):
    def _construct_forms(self):
        # instantiate all the forms and put them in self.forms
        self.forms = []
        for i in xrange(self.total_form_count()):
            self.forms.append(self._construct_form(i, parent_instance=self.instance))

    def _get_empty_form(self, **kwargs):
        return super(ParentInstInlineFormSet, self)._get_empty_form(parent_instance=self.instance)
    empty_form = property(_get_empty_form)


class ParentInlineModelForm(ModelForm):
    def __init__(self, *args, **kwargs):
        self.parent_instance = kwargs.pop('parent_instance', None)
        super(ParentInlineModelForm, self).__init__(*args, **kwargs)

in class RoomInline just add:

class RoomInline(admin.TabularInline):
      formset = ParentInstInlineFormset
      form = RoomInlineForm #(or something)

In your form you now have access in the init method to self.parent_instance! parent_instance can now be used to filter choices and whatnot

something like:

class RoomInlineForm(ParentInlineModelForm):
    def __init__(self, *args, **kwargs):
        super(RoomInlineForm, self).__init__(*args, **kwargs)
        building = self.parent_instance
        #Filtering and stuff
Silveira answered 12/4, 2012 at 19:9 Comment(1)
Thank you for this! Its the first version that worked for my application and its nice and clear too.Poll
H
8

The problem in @nogus answer there's still wrong url in popup /?_to_field=id&_popup=1

which allow user to select wrong item in popup

To finally make it work I had to change field.widget.rel.limit_choices_to dict

class RoomInline(admin.TabularInline):
    model = Room

    def formfield_for_foreignkey(self, db_field, request=None, **kwargs):

        field = super(RoomInline, self).formfield_for_foreignkey(
            db_field, request, **kwargs)

        if db_field.name == 'inside_room':
            building = request._obj_
            if building is not None:
                field.queryset = field.queryset.filter(
                    building__exact=building)
                # widget changed to filter by building
                field.widget.rel.limit_choices_to = {'building_id': building.id}
            else:
                field.queryset = field.queryset.none()

        return field

class BuildingAdmin(admin.ModelAdmin):

    inlines = (RoomInline,)

    def get_form(self, request, obj=None, **kwargs):
        # just save obj reference for future processing in Inline
        request._obj_ = obj
        return super(BuildingAdmin, self).get_form(request, obj, **kwargs)
Hopehopeful answered 11/5, 2018 at 18:52 Comment(1)
This worked for me in django 2.2 without needing to use field.widget.rel.limit_choices_to = {'building_id': building.id}Sunburst
M
5

This question and answer is very similar, and works for a regular admin form

Inside of an inline--and that's where it falls apart... I just can't get at the main form's data to get the foreign key value I need in my limit (or to one of the inline's records to grab the value).

Here's my admin.py. I guess I'm looking for the magic to replace the ???? with--if I plug in a hardcoded value (say, 1), it works fine and properly limits the the available choices in the inline...

#spaces/admin.py
from demo.spaces.models import Building, Room
from django.contrib import admin
from django.forms import ModelForm


class RoomInlineForm(ModelForm):
  def __init__(self, *args, **kwargs):
    super(RoomInlineForm, self).__init__(*args, **kwargs)
    self.fields['inside_room'].queryset = Room.objects.filter(
                               building__exact=????)                       # <------

class RoomInline(admin.TabularInline):
  form = RoomInlineForm
  model=Room

class BuildingAdmin(admin.ModelAdmin):
  inlines=[RoomInline]

admin.site.register(Building, BuildingAdmin)
admin.site.register(Room)
Massengale answered 2/12, 2009 at 2:30 Comment(0)
M
4

I found a fairly elegant solution that works well for inline forms.

Applied to my model, where I'm filtering the inside_room field to only return rooms that are in the same building:

#spaces/admin.py
class RoomInlineForm(ModelForm):
  def __init__(self, *args, **kwargs):
    super(RoomInlineForm, self).__init__(*args, **kwargs)  #On init...
  if 'instance' in kwargs:
    building = kwargs['instance'].building
  else:
    building_id = tuple(i[0] for i in self.fields['building'].widget.choices)[1]
    building = Building.objects.get(id=building_id)
  self.fields['inside_room'].queryset = Room.objects.filter(building__exact=building)

Basically, if an 'instance' keyword is passed to the form, it's an existing record showing in the inline, and so I can just grab the building from the instance. If not an instance, it's one of the blank "extra" rows in the inline, and so it goes through the hidden form fields of the inline that store the implicit relation back to the main page, and grabs the id value from that. Then, it grabs the building object based on that building_id. Finally, now having the building, we can set the queryset of the drop downs to only display the relevant items.

More elegant than my original solution, which crashed and burned as inline (but worked--well, if you don't mind saving the form partway to make the drop downs fill in-- for the individual forms):

class RoomForm(forms.ModelForm): # For the individual rooms
  class Meta:
mode = Room
  def __init__(self, *args, **kwargs):  # Limits inside_room choices to same building only
    super(RoomForm, self).__init__(*args, **kwargs)  #On init...
try:
  self.fields['inside_room'].queryset = Room.objects.filter( 
    building__exact=self.instance.building)   # rooms with the same building as this room
    except:                  #and hide this field (why can't I exclude?)
    self.fields['inside_room']=forms.CharField( #Add room throws DoesNotExist error
        widget=forms.HiddenInput,   
        required=False,
        label='Inside Room (save room first)')

For non-inlines, it worked if the room already existed. If not, it would throw an error (DoesNotExist), so I'd catch it and then hide the field (since there was no way, from the Admin, to limit it to the right building, since the whole room record was new, and no building was yet set!)...once you hit save, it saves the building and on reload it could limit the choices...

I just need to find a way to cascade the foreign key filters from one field to another in a new record--i.e., new record, select a building, and it automatically limits the choices in the inside_room select box--before the record gets saved. But that's for another day...

Massengale answered 9/12, 2009 at 2:39 Comment(0)
H
2

I have to admit, I didn't follow exactly what you're trying to do, but I think it's complex enough that you might want to consider not basing your site off of the admin.

I built a site once that started out with the simple admin interface, but eventually became so customized that it became very difficult to work with within the constraints of the admin. I would have been better off if I'd just started from scratch--more work at the beginning, but a lot more flexibility and less pain at the end. My rule-of-thumb would be if that what you're trying to do is not documented (ie. involves overriding admin methods, peering into the admin source code etc.) then you're probably better off not using the admin. Just me two cents. :)

Homelike answered 1/12, 2009 at 17:9 Comment(1)
Sometimes I create sites where forms are used extensively. In that case make sure ..! It is mandatory to use the admin section to manipulate the tables. Other than that, I agree with what you said.Connaught
E
2

In django 1.6:

 form = SpettacoloForm( instance = spettacolo )
 form.fields['teatro'].queryset = Teatro.objects.filter( utente = request.user ).order_by( "nome" ).all()
Eulaliaeulaliah answered 18/7, 2014 at 15:8 Comment(1)
Could you please adapt the solution to the models existing in the question?Kass
S
1

If Daniel, after editing your question, hasn't answered - I don't think I will be much help... :-)

I'm going to suggest that you are trying to force fit into the django admin some logic that would be better off implemented as your own group of views, forms and templates.

I don't think it is possible to apply that sort of filtering to the InlineModelAdmin.

Shoelace answered 1/12, 2009 at 16:5 Comment(0)
I
0

I found this also looking for a solution, so I wanted to share a clean one I've found for Django 4.2 (probably works for some older versions as well).

class MyInline(admin.StackedInline):
    model = MyModel

    def get_field_queryset(self, db, db_field, request):
        """ Apply filters etc. on a particular field """
        qs = super().get_field_queryset(db, db_field, request)
        if db_field.name == "my_field":
            qs = qs.filter(...)
        return qs
Ilex answered 22/3, 2024 at 14:15 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.