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),
        }