Forms

django-tenant-options provides form mixins and fields that handle tenant-aware option filtering. This guide covers each one in detail.

Every form mixin in this package requires a tenant argument passed from your view. Forgetting this raises NoTenantProvidedFromViewError.

UserFacingFormMixin

Use this mixin for forms that end users interact with – task creation forms, order forms, any form where a user selects from available options.

from django import forms
from django_tenant_options.forms import UserFacingFormMixin


class TaskForm(UserFacingFormMixin, forms.ModelForm):
    class Meta:
        model = Task
        fields = ["title", "priority", "status"]

What it does automatically

When initialized with a tenant, the mixin:

  1. Finds ForeignKey fields pointing to any AbstractOption subclass

  2. Filters their querysets to show only options the tenant has selected (via selected_options_for_tenant())

  3. Hides the tenant field if present (sets it to HiddenInput)

  4. Removes the associated_tenants field if present

  5. Handles deleted selections – if an existing record references a deleted option, the behavior depends on the DISABLE_FIELD_FOR_DELETED_SELECTION setting

Deleted selection behavior

When a tenant deselects an option but existing records still reference it:

  • DISABLE_FIELD_FOR_DELETED_SELECTION = False (default): The user must select a new option when editing the record. The deleted option won’t appear as a choice.

  • DISABLE_FIELD_FOR_DELETED_SELECTION = True: The deleted option appears in the dropdown but is disabled (read-only). The user sees what was previously selected but can’t change it.

In both cases, deleted options never appear in forms for new records.

View integration

Pass tenant from your view:

# Function-based view
def task_create(request):
    if request.method == "POST":
        form = TaskForm(request.POST, tenant=request.user.tenant)
        if form.is_valid():
            form.save()
            return redirect("task_list")
    else:
        form = TaskForm(tenant=request.user.tenant)
    return render(request, "tasks/form.html", {"form": form})

# Class-based view
class TaskCreateView(LoginRequiredMixin, CreateView):
    model = Task
    form_class = TaskForm

    def get_form_kwargs(self):
        kwargs = super().get_form_kwargs()
        kwargs["tenant"] = self.request.user.tenant
        return kwargs

OptionCreateFormMixin

Use this mixin for forms that let tenant admins create new custom options.

from django import forms
from django_tenant_options.forms import OptionCreateFormMixin


class TaskPriorityCreateForm(OptionCreateFormMixin, forms.ModelForm):
    class Meta:
        model = TaskPriorityOption
        fields = ["name", "option_type", "tenant", "deleted"]

What it does automatically

  1. Validates the tenant and hides the tenant field (HiddenInput)

  2. Sets option_type to OptionType.CUSTOM and hides it – tenant-created options are always custom

  3. Hides the deleted field and initializes it to None

  4. Removes associated_tenants if present

  5. Enforces option_type = CUSTOM in clean() – even if someone manipulates the hidden field

Specifying fields

You can use fields = "__all__" or list specific fields. Either way, the mixin will hide fields that tenants shouldn’t see (tenant, option_type, deleted):

# Explicit fields -- recommended for clarity
class TaskPriorityCreateForm(OptionCreateFormMixin, forms.ModelForm):
    class Meta:
        model = TaskPriorityOption
        fields = ["name", "option_type", "tenant", "deleted"]

# Implicit -- the mixin hides what it needs to
class TaskPriorityCreateForm(OptionCreateFormMixin, forms.ModelForm):
    class Meta:
        model = TaskPriorityOption
        fields = "__all__"

OptionUpdateFormMixin

Extends OptionCreateFormMixin with a delete checkbox for soft-deleting options.

from django import forms
from django_tenant_options.forms import OptionUpdateFormMixin


class TaskPriorityUpdateForm(OptionUpdateFormMixin, forms.ModelForm):
    class Meta:
        model = TaskPriorityOption
        fields = "__all__"

What it adds

On top of everything OptionCreateFormMixin does:

  1. Adds a delete BooleanField (not required, defaults to False)

  2. In clean(), if delete is checked, sets cleaned_data["deleted"] to the current timestamp

The resulting form lets tenant admins rename a custom option or soft-delete it in a single form.

View integration

def priority_update(request, option_id):
    option = get_object_or_404(TaskPriorityOption, id=option_id)
    if request.method == "POST":
        form = TaskPriorityUpdateForm(
            request.POST,
            instance=option,
            tenant=request.user.tenant,
        )
        if form.is_valid():
            form.save()
            return redirect("priority_list")
    else:
        form = TaskPriorityUpdateForm(
            instance=option,
            tenant=request.user.tenant,
        )
    return render(request, "priorities/form.html", {"form": form})

SelectionsForm

Use this form to let tenant admins manage which options are enabled for their tenant.

from django_tenant_options.forms import SelectionsForm


class TaskPrioritySelectionForm(SelectionsForm):
    class Meta:
        model = TaskPrioritySelection

What it does

  1. Creates a selections field – a ModelMultipleChoiceField showing all options available to the tenant

  2. Pre-selects currently enabled options as initial values

  3. In clean(), automatically includes all mandatory options (even if the tenant tries to deselect them)

  4. Identifies removed selections – options that were previously selected but aren’t in the new submission

  5. In save(), uses an atomic transaction to:

    • Soft-delete removed selections (sets deleted timestamp)

    • Create or restore selections for newly chosen options

Customizing the widget

You can adjust the selection widget in your form’s __init__:

class TaskPrioritySelectionForm(SelectionsForm):
    class Meta:
        model = TaskPrioritySelection

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["selections"].widget.attrs["size"] = "10"

View integration

def priority_selections(request):
    if request.method == "POST":
        form = TaskPrioritySelectionForm(
            request.POST,
            tenant=request.user.tenant,
        )
        if form.is_valid():
            form.save()
            return redirect("priority_list")
    else:
        form = TaskPrioritySelectionForm(tenant=request.user.tenant)
    return render(request, "priorities/selections.html", {"form": form})

TenantFormBaseMixin

This is the base mixin that OptionCreateFormMixin and SelectionsForm build on. You generally don’t use it directly, but it’s useful to understand what it provides:

  • Pops tenant from kwargs and validates it’s not None

  • Hides the tenant field if present

  • Sets option_type to CUSTOM if the field exists

  • Removes associated_tenants if present

  • Overrides clean() to ensure tenant is always correct in cleaned data

OptionsModelMultipleChoiceField

A custom ModelMultipleChoiceField used by SelectionsForm that displays option type labels alongside option names.

The default label_from_instance shows: "Option Name (Mandatory)", "Option Name (Optional)", or "Option Name (Custom)".

Custom display

To customize how options appear in the selection widget, subclass the field:

from django_tenant_options.form_fields import OptionsModelMultipleChoiceField


class CustomOptionsField(OptionsModelMultipleChoiceField):
    def label_from_instance(self, obj):
        return f"{obj.name} - {obj.get_option_type_display()}"

Then configure it globally:

DJANGO_TENANT_OPTIONS = {
    "DEFAULT_MULTIPLE_CHOICE_FIELD": "yourapp.forms.CustomOptionsField",
}

Grouped option fields

GroupedOptionsModelMultipleChoiceField is a drop-in replacement for OptionsModelMultipleChoiceField that renders selectable options inside HTML <optgroup> groups. This keeps long option catalogs navigable. It subclasses OptionsModelMultipleChoiceField, so option labels still show their type suffix (for example High (mandatory)) inside each group.

By default it groups by option_type, ordering the groups Mandatory -> Optional -> Custom. You can group by any attribute on your option model by passing group_by; objects whose attribute is missing or empty fall into an Uncategorized group, and the remaining groups are sorted alphabetically.

Use it project-wide

Point the package setting at the grouped field’s dotted path:

DJANGO_TENANT_OPTIONS = {
    "DEFAULT_MULTIPLE_CHOICE_FIELD": "django_tenant_options.form_fields.GroupedOptionsModelMultipleChoiceField",
}

Every SelectionsForm then renders grouped selections automatically.

Use it on a single form

Set the multiple_choice_field_class class attribute on a SelectionsForm subclass. Forms that do not set it are unaffected:

from django_tenant_options.forms import SelectionsForm
from django_tenant_options.form_fields import GroupedOptionsModelMultipleChoiceField


class GroupedSelectionsForm(SelectionsForm):
    multiple_choice_field_class = GroupedOptionsModelMultipleChoiceField

    class Meta:
        model = TaskPrioritySelection

Grouping by a custom attribute

If your option model exposes an attribute such as category (for example via a metadata mixin), subclass the field with group_by defaulted, then reference it from your form:

from django_tenant_options.form_fields import GroupedOptionsModelMultipleChoiceField


class CategoryGroupedField(GroupedOptionsModelMultipleChoiceField):
    def __init__(self, *args, **kwargs):
        kwargs.setdefault("group_by", "category")
        super().__init__(*args, **kwargs)


class CategorySelectionsForm(SelectionsForm):
    multiple_choice_field_class = CategoryGroupedField

    class Meta:
        model = TaskPrioritySelection

If the option model has no category attribute, every option simply appears under Uncategorized - the field degrades gracefully rather than raising.

Common mistakes

Forgetting to pass tenant

Every form that uses these mixins requires tenant= in its constructor call:

# This will raise NoTenantProvidedFromViewError
form = TaskForm(request.POST)

# Correct
form = TaskForm(request.POST, tenant=request.user.tenant)

Using the wrong mixin

  • End-user forms (selecting an option for a record): UserFacingFormMixin

  • Creating custom options (tenant admin): OptionCreateFormMixin

  • Updating/deleting custom options (tenant admin): OptionUpdateFormMixin

  • Managing which options are enabled (tenant admin): SelectionsForm

Combining with crispy-forms

The mixins work with django-crispy-forms. Initialize the crispy helper after calling super().__init__():

class TaskPriorityCreateForm(OptionCreateFormMixin, ModelForm):
    class Meta:
        model = TaskPriorityOption
        fields = "__all__"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper = FormHelper()
        self.helper.layout = Layout(
            Fieldset("Create Priority", *self.fields.keys()),
            Submit("submit", "Save"),
        )

Further reading