Example Usage¶
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.
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)
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.
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)
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¶
Because of that, Guest
will have two foreign keys (and a bit of redundancy):
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):
LIMIT = (
Q(app_label='generic_relation', model='item') |
Q(app_label='generic_relation', model='person')
)
content_type = models.ForeignKey(
ContentType, on_delete=models.CASCADE, limit_choices_to=LIMIT)
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
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
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)
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),
}