Example usage cases¶
Limiting widget queryset¶
Consider a simple, naive example. We want to assign guests to hotel rooms, but for some certain reason we want to have a possibility to assign guests to hotels without assigning them directly to any room.
Models¶
Because of that, Guest
will have two foreign keys (and a bit of redundancy):
from django.db import models
class Hotel(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Room(models.Model):
hotel = models.ForeignKey(Hotel, related_name='rooms')
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Guest(models.Model):
hotel = models.ForeignKey(Hotel, related_name='guests')
room = models.ForeignKey(Room, related_name='guests', null=True, blank=True)
name = models.CharField(max_length=200)
Widgets¶
We want an admin form widget, which will narrow room choices only to those corresponding to currently selected hotel. Before we write the admin and admin form, let’s start with widgets:
from django_select2.forms import ModelSelect2Widget
from linked_select2.forms import LinkedModelSelect2Widget
from .models import Hotel, Room
class HotelSelect2Mixin(object):
"""
Hotel selecting widget, just like it would have been in django_select2.
"""
model = Hotel
search_fields = [
'name__icontains',
]
class HotelSelect2(HotelSelect2Mixin, ModelSelect2Widget):
pass
class RoomSelect2Mixin(object):
"""
Narrows down room choices to currently selected hotel.
"""
model = Room
search_fields = [
'name__icontains',
]
def get_queryset(self, form):
"""
If a linked field contains a hotel, narrow down room choices to that
particular hotel. Otherwise, return empty list.
"""
hotel = form.cleaned_data.get('hotel')
queryset = super().get_queryset()
if hotel:
queryset = queryset.filter(hotel=hotel)
else:
queryset = queryset.none()
return queryset
class RoomSelect2(RoomSelect2Mixin, LinkedModelSelect2Widget):
pass
HotelSelect2
is an instance of original django_select2.ModelSelect2Widget
widget without any modifications. RoomSelect2
is an instance of extended
linked_select2.LinkedModelSelect2Widget
widget, which gives access to other
(not submitted by the user yet) field values in get_queryset
method.
The RoomSelect2.get_queryset
method is passed a form, which will contain a
hotel
field. The widget class connects the form field names with
corresponding HTML input elements in the frontend and every time an AJAX fetch
is happening, the values from these elements are passed as query parameters
along with the search query. Both widgets will be used in a form of
guest admin.
Admin¶
Here we define the admin:
from django.contrib import admin
from .forms import GuestAdminForm
from .models import Guest
class GuestAdmin(admin.ModelAdmin):
form = GuestAdminForm
admin.site.register(Guest, GuestAdmin)
Forms¶
And here we define the admin form:
from django import forms
from django.utils.translation import ugettext_lazy as _
from .models import Hotel
from .widgets import HotelSelect2, RoomSelect2
class RoomWidgetForm(forms.Form):
hotel = forms.ModelChoiceField(queryset=Hotel.objects.all())
class GuestAdminForm(forms.ModelForm):
def clean(self):
cleaned_data = super().clean()
room = cleaned_data.get('room')
hotel = cleaned_data.get('hotel')
if room is not None:
if hotel is None:
self.add_error(
'room',
_("Cannot set room without setting hotel.")
)
elif room.hotel != hotel:
self.add_error(
'room',
_("'%(room)s' is not a room of '%(hotel)s' hotel." % {
'room': room,
'hotel': hotel,
})
)
class Meta:
widgets = {
'hotel': HotelSelect2,
'room': RoomSelect2(form=RoomWidgetForm),
}
The GuestAdminForm.clean
method ensures that if room is set, the hotel field
must be set aswell and it must be the same exact hotel as the one of
selected room.
We override default widgets in GuestAdminForm.Meta
class and we pass an
argument form
to RoomSelect2
constructor - this form contains a hotel
field. All HTML input elements with ids corresponding to field names of this
form (only hotel
in this case) will be connected with the room widget.
Using generic relations¶
In another naive example, we have two models: Item
and Person
.
We also have a third model called Note
, which has a generic foreign key to
these two models, which simply allows to add some annotations to their objects.
We are by default able to add notes to people and items through admin inlines,
but what if we want to add notes to objects directly through NoteAdmin? This
short example shows, how to create widgets for two fields: content_type
and
object_id
, the latter using linked_select2.LinkedModelSelect2Widget
in
order to display objects from currently selected content type. The files are
going to be shown in a slightly different order than in previous example.
Models¶
We add a limit_choices_to argument to content_type field declaration to narrow down the choices to two content types.
from django.contrib.contenttypes.fields import (
GenericForeignKey, GenericRelation,
)
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
class Note(models.Model):
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, limit_choices_to=(
Q(app_label='generic_relation', model='item') |
Q(app_label='generic_relation', model='person')
)
)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
text = models.CharField(max_length=200)
def __str__(self):
return self.text
class Item(models.Model):
notes = GenericRelation(Note)
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Person(models.Model):
notes = GenericRelation(Note)
name = models.CharField(max_length=200)
def __str__(self):
return self.name
Admin¶
In admin.py
we don’t do anything spectacular, but it’s important to
remebmer to assign form attribute to NoteAdmin
in order to override its
field widgets.
from django.contrib import admin
from django.contrib.contenttypes.admin import GenericTabularInline
from .forms import NoteAdminForm
from .models import Item, Note, Person
class NoteInline(GenericTabularInline):
model = Note
extra = 1
class ItemAdmin(admin.ModelAdmin):
inlines = [
NoteInline
]
class PersonAdmin(admin.ModelAdmin):
inlines = [
NoteInline
]
class NoteAdmin(admin.ModelAdmin):
form = NoteAdminForm
admin.site.register(Note, NoteAdmin)
admin.site.register(Item, ItemAdmin)
admin.site.register(Person, PersonAdmin)
Widgets¶
from django.contrib.contenttypes.models import ContentType
from django_select2.forms import ModelSelect2Widget
from linked_select2.forms import LinkedModelSelect2Widget
class ContentTypeSelect2Mixin(object):
"""
Contenttype selecting widget, just like it would've been in django_select2.
"""
model = ContentType
search_fields = [
'app_label__icontains',
'model__icontains',
]
class ContentTypeSelect2(ContentTypeSelect2Mixin, ModelSelect2Widget):
pass
class ObjectIdSelect2Mixin(object):
"""
Fetches list of object ids based upon currently selected content type
value in the frontend.
"""
model = None # Stated explicitly to be clear
search_fields = [
'name__icontains',
]
def get_queryset(self, form):
"""
If a linked field contains a content type, return queryset containing
objects from that particular content type model.
Otherwise, return `None`, which implies that the queryset is empty and
has no model.
"""
content_type = form.cleaned_data.get('content_type')
if content_type:
return content_type.model_class()._default_manager.all()
return None # Returning `None` implies empty, model-less queryset
class ObjectIdSelect2(ObjectIdSelect2Mixin, LinkedModelSelect2Widget):
pass
ContentTypeSelect2
is a widget using django_select2
class and it
simply returns a list of available content types.
ObjectIdSelect2
is using linked_select2
class and overrides
get_queryset
method in order to select model basing on selected content type
and then it returns queryset containing all objects from this model.
The method could of course be extended in order to add some extra filtering
during the process, but that’s not necessary in this case.
Forms¶
The last part of adding our widget is defining the form which will contain them. There are two issues that need to be handled:
1. Making sure that the widget will not return objects from other content types.
A malicious user could change selected content_type
value in the frontend
in order to get list of objects belonging to other models than
Item
or Person
. This problem will be handled by filtering the queryset
of content_type
in the linked widget form
(the ObjectIdWidgetForm.limit_content_type_choices
method).
2. Render the initial value with correct display value when loading the page.
The widget has initially no knowledge of selected content type. This problem
will be handled by manually assigning the object_id
choices to one-item
list containing the object id as value and textual representation of correct
model instance with that object id
(the NoteAdminForm.set_initial_choices
method).
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.encoding import force_text
from .models import Note
from .widgets import ContentTypeSelect2, ObjectIdSelect2
class ObjectIdWidgetForm(forms.Form):
content_type = forms.ModelChoiceField(queryset=ContentType.objects.all())
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.limit_content_type_choices()
def limit_content_type_choices(self):
"""
Limits the available content type choices to ones defined in
Note model.
This is **very important** to prevent "accidently" leaking list of
objects belonging to any other content types.
"""
limit = Note._meta.get_field('content_type').get_limit_choices_to()
if limit:
self.fields['content_type'].queryset = (
ContentType.objects.filter(limit))
class NoteAdminForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_initial_choices()
def set_initial_choices(self):
"""
If instance has `content_type` and `object_id` set, set initial
`object_id` choices to `object_id` with `content_type` model as
display value.
"""
obj = self.instance
if getattr(obj, 'content_type', None) and obj.object_id:
generic_model = obj.content_type.model_class()
generic_object = generic_model._default_manager.get(
pk=obj.object_id)
self.fields['object_id'].widget.choices = [
(obj.object_id, force_text(generic_object)),
]
class Meta:
widgets = {
'content_type': ContentTypeSelect2,
'object_id': ObjectIdSelect2(form=ObjectIdWidgetForm),
}