Filtering Foreign Keys within Django Inline Classes
Jan. 28, 2019
Several months ago I posted a blog discussing how to perform form validation within the Django Admin (https://www.cloudstormllc.com/blog/19/validating-forms-within-the-django-admin). I discussed the use case thoroughly in the previous blog so I'm not going to go into detail again. In summary the use case was validating Notes by ensuring each Note for an Organization also includes a Contact who is an employee of the Organization. After all, it doesn't make sense to document a customer visit (via a "Note") and include a Contact who isn't associated with the Organization for which the Note is created. The previous blog demonstrated how a Note could be created from within the Note view of the Django Admin. From that view a user writes a Note and then selects the Organization and Contact before saving the Note. That requires validation after a User attempts to save a Note.
This may sound a little confusing so let's review the Django Note model:
class Note(models.Model): note = models.TextField(_('Call Note'), max_length=1000, blank=False, null=True) user = models.ForeignKey(User, related_name='user_notes', null=True) contact = models.ForeignKey(Contact, null=True) organization = models.ForeignKey(Organization, related_name='organization_notes', null=True) def __str__(self): return self.note
The model itself is very straight forward. There is a TextField to represent the sales rep's notes and there are three foreign key relationships; 1) user represents a sales rep which is represented by Django's User model, 2) contact is a reference to a Contact object (which also has a foreign key relationship to an Organization), and 3) organization is a reference to a customer Organization. Note validation requires that a Note's Contact attribute also have a foreign key relationship to the Organization for which the Note is written. The previous post demonstrated how to do this using the NoteAdmin and a supporting form named NoteForm.
The validation works fine, but people are like electrons and prefer to take the path of least resistance. It didn't take long for users to begin requesting more efficient means of interacting with the system. The Organization model quickly turned out to be the center of gravity for the application. Users prefer to view an Organization and within the Organization view they want to see Contacts who belong to the Organization and all Notes written for the Organization. Django inlines to the rescue! However, Django inlines present a challenge because they don't follow the same path used by the NoteAdmin class. This is a problem for two reasons. The first reason is that all Contacts are present in the dropdown list when adding a new Note from the Note inline within the Organizaiton Admin view. However, the more serious issue is the fact that a Note can be added to the selected Organization using a Contact that doesn't belong to the Organization because the validation is bypassed.
Hmm...we have a problem. Our users demand the ability to use the Organization view as their primary interface, but that means there is a risk of the wrong Contact being assigned to an organizational Note. Also, no one likes selecting a Contact from the thousands stored in the system (there are between 3 - 20 Contacts per Organization). What do do? I got it! What if we can filter the list of Contacts available to select when adding a new Note from the inline within an Organization's view? After all, we already have a reference to an Organization if we are operating within its view. And we know a Contact has a foreign key relationship to an Organization which means that an Organization has a one to many relationship with Contacts. Now we are getting somewhere!
Organization Admin View Before
Below is an example of the Note inlines within the Organization view in the before we start working on our filtering:
Did you notice how the Note inline appears to show the note text twice? The reason that happens is because Django inline classes show the string representation, and from our Note model you can see that str is set to self.note which is the text of the Note. Let's make sure we deal with that as well because some Notes may be very long.
Organization Admin
Below is a snippet from the Organization Admin class:
class OrganizationAdmin(admin.ModelAdmin): model = Organization fieldsets = [ (None, {'fields': ['organization_name', 'address1', 'address2', 'city', 'state', 'zipcode', 'region', 'country', 'website', 'customer_type', 'ab_number', 'is_customer', 'is_parent', 'parent']}), ] inlines = [ContactInLine, AddContactInLine, NoteInLine, AddNoteInLine] def get_form(self, request, obj=None, **kwargs): request._obj_ = obj return super(OrganizationAdmin, self).get_form(request, obj, **kwargs) class Media: css = {"all": ("css/hide_admin_original.css",)}
Note the inline definitions which includes AddNoteInLine. This is important
because this is the inline where we will want to filter the Contacts for a
given Organization. The other critical item is the get_form
method. The
request gets passed around and when an Organization is selected the
obj attribute represents a reference to an Organization which we will need
to filter the list of Contacts (each Contact has its own FK to an Organization). That's why we add the obj attribute to
our request object (obj is not passed to the inline classes). Also, note the
Media class. That's the code we need to hide the string representation of
the Note inlines from appearing.
We are halfway there. The specified inlines are loaded and it is in the AddNoteInLine class where we need to filter and return only Contacts for the selected Organization (NoteInLine is a read only view of existing Notes).
Below is the code for the AddNoteInLine class:
class AddNoteInLine(admin.TabularInline): model = Note classes = ['collapse'] def has_change_permission(self, request, obj=None): return False def has_add_permission(self, request, obj=None): return True def has_delete_permission(self, request, obj=None): return False extra = 0 def formfield_for_foreignkey(self, db_field, request=None, **kwargs): field = super(AddNoteInLine, self).formfield_for_foreignkey(db_field, request, **kwargs) if db_field.name == 'contact': if request._obj_ is not None: field.queryset = field.queryset.filter(organization__exact=request._obj_) else: field.queryset = field.queryset.none() return field
The relevant code above is the formfield_for_foreignkey
method. The
formfield_for_foreignkey
method on a ModelAdmin allows you to override
the default formfield for a foreign keys field. Recall
the get_form
method invocation in the OrganizationAdmin class. When Django
loads the inline classes it processes our custom formfield_for_foreignkey
method
because we have overridden the default which would have returned all Contacts.
At this point we have the request object and db_field
is a reference to
the different attributes of an Organization. In this case we are only
interested in Contacts that belong to an Organization (contact
is the
attribute in the Note model that represents a foreign key relationship).
When the db_field.name matches the contact
attribute we spring out trap!
Recall we added an attribute names obj that is a reference to an
Organization to our request object. We now use that to filter Contacts
who belong to the selected Organization which, of course, is the Organization
that was selected from within the Django Admin.
Organization Admin View After
When a user adds a new Note from the Organization Admin view the code above is triggered and only the current Organization's Contacts are available to be selected. The Note can be added and saved within the inline with the knowledge that the Note is valid meaning that the Contact belongs to the Organization. Below is how the filtered list will appear and note that the string representation no longer appears.
We covered some advanced features of the Django Admin interface, but hopefully this blog will be helpful if you have a need to filter foreign keys within a Django inline class. The tip about hiding the string representation of an inline class is also a very handy tip I stumbled across on Stack one day. Happy coding and feel free to enter a comment...we'd love to hear from you!