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