Skip to content

nautobot.apps.forms

Forms and fields for apps to use.

nautobot.apps.forms.APISelect

Bases: SelectWithDisabled

A select widget populated via an API call

Parameters:

Name Type Description Default
api_url str

API endpoint URL. Required if not set automatically by the parent field.

None
api_version str

API version.

None
Source code in nautobot/core/forms/widgets.py
class APISelect(SelectWithDisabled):
    """
    A select widget populated via an API call

    Args:
        api_url (str): API endpoint URL. Required if not set automatically by the parent field.
        api_version (str): API version.
    """

    def __init__(self, api_url=None, full=False, api_version=None, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.attrs["class"] = "nautobot-select2-api"

        if api_version:
            # Set Request Accept Header api-version e.g Accept: application/json; version=1.2
            self.attrs["data-api-version"] = api_version

        if api_url:
            # Prefix the URL w/ the script prefix (e.g. `/nautobot`)
            self.attrs["data-url"] = urljoin(get_script_prefix(), api_url.lstrip("/"))

    def add_query_param(self, name, value):
        """
        Add details for an additional query param in the form of a data-* JSON-encoded list attribute.

        :param name: The name of the query param
        :param value: The value of the query param
        """
        key = f"data-query-param-{name}"

        values = json.loads(self.attrs.get(key, "[]"))
        if isinstance(value, (list, tuple)):
            values.extend([str(v) for v in value])
        else:
            values.append(str(value))

        self.attrs[key] = json.dumps(values, ensure_ascii=False)

    def get_context(self, name, value, attrs):
        # This adds null options to DynamicModelMultipleChoiceField selected choices
        # example <select ..>
        #           <option .. selected value="null">None</option>
        #           <option .. selected value="1234-455...">Rack 001</option>
        #           <option .. value="1234-455...">Rack 002</option>
        #          </select>
        # Prepend null choice to self.choices if
        # 1. form field allow null_option e.g. DynamicModelMultipleChoiceField(..., null_option="None"..)
        # 2. if null is part of url query parameter for name(field_name) i.e. http://.../?rack_id=null
        # 3. if both value and choices are iterable
        if (
            self.attrs.get("data-null-option")
            and isinstance(value, (list, tuple))
            and "null" in value
            and isinstance(self.choices, Iterable)
        ):

            class ModelChoiceIteratorWithNullOption(ModelChoiceIterator):
                def __init__(self, *args, **kwargs):
                    self.null_options = kwargs.pop("null_option", None)
                    super().__init__(*args, **kwargs)

                def __iter__(self):
                    # ModelChoiceIterator.__iter__() yields a tuple of (value, label)
                    # using this approach first yield a tuple of (null(value), null_option(label))
                    yield "null", self.null_options
                    for item in super().__iter__():
                        yield item

            null_option = self.attrs.get("data-null-option")
            self.choices = ModelChoiceIteratorWithNullOption(field=self.choices.field, null_option=null_option)

        return super().get_context(name, value, attrs)

add_query_param(name, value)

Add details for an additional query param in the form of a data-* JSON-encoded list attribute.

:param name: The name of the query param :param value: The value of the query param

Source code in nautobot/core/forms/widgets.py
def add_query_param(self, name, value):
    """
    Add details for an additional query param in the form of a data-* JSON-encoded list attribute.

    :param name: The name of the query param
    :param value: The value of the query param
    """
    key = f"data-query-param-{name}"

    values = json.loads(self.attrs.get(key, "[]"))
    if isinstance(value, (list, tuple)):
        values.extend([str(v) for v in value])
    else:
        values.append(str(value))

    self.attrs[key] = json.dumps(values, ensure_ascii=False)

nautobot.apps.forms.AddressFieldMixin

Bases: forms.ModelForm

ModelForm mixin for IPAddress based models.

Source code in nautobot/core/forms/forms.py
class AddressFieldMixin(forms.ModelForm):
    """
    ModelForm mixin for IPAddress based models.
    """

    address = formfields.IPNetworkFormField()

    def __init__(self, *args, **kwargs):
        instance = kwargs.get("instance")
        initial = kwargs.get("initial", {}).copy()

        # If initial already has an `address`, we want to use that `address` as it was passed into
        # the form. If we're editing an object with a `address` field, we need to patch initial
        # to include `address` because it is a computed field.
        if "address" not in initial and instance is not None:
            initial["address"] = instance.address

        kwargs["initial"] = initial

        super().__init__(*args, **kwargs)

    def clean(self):
        super().clean()

        # Need to set instance attribute for `address` to run proper validation on Model.clean()
        self.instance.address = self.cleaned_data.get("address")

nautobot.apps.forms.BootstrapMixin

Bases: forms.BaseForm

Add the base Bootstrap CSS classes to form elements.

Source code in nautobot/core/forms/forms.py
class BootstrapMixin(forms.BaseForm):
    """
    Add the base Bootstrap CSS classes to form elements.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        exempt_widgets = [
            forms.CheckboxInput,
            forms.ClearableFileInput,
            forms.FileInput,
            forms.RadioSelect,
        ]

        for field in self.fields.values():
            if field.widget.__class__ not in exempt_widgets:
                css = field.widget.attrs.get("class", "")
                field.widget.attrs["class"] = " ".join([css, "form-control"]).strip()
            if field.required and not isinstance(field.widget, forms.FileInput):
                field.widget.attrs["required"] = "required"
            if "placeholder" not in field.widget.attrs:
                field.widget.attrs["placeholder"] = field.label

nautobot.apps.forms.BulkEditForm

Bases: forms.Form

Base form for editing multiple objects in bulk.

Note that for models supporting custom fields and relationships, nautobot.extras.forms.NautobotBulkEditForm is a more powerful subclass and should be used instead of directly inheriting from this class.

Source code in nautobot/core/forms/forms.py
class BulkEditForm(forms.Form):
    """
    Base form for editing multiple objects in bulk.

    Note that for models supporting custom fields and relationships, nautobot.extras.forms.NautobotBulkEditForm is
    a more powerful subclass and should be used instead of directly inheriting from this class.
    """

    def __init__(self, model, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.model = model
        self.nullable_fields = []

        # Copy any nullable fields defined in Meta
        if hasattr(self.Meta, "nullable_fields"):
            self.nullable_fields = self.Meta.nullable_fields

nautobot.apps.forms.BulkEditNullBooleanSelect

Bases: forms.NullBooleanSelect

A Select widget for NullBooleanFields

Source code in nautobot/core/forms/widgets.py
class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
    """
    A Select widget for NullBooleanFields
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        # Override the built-in choice labels
        self.choices = (
            ("1", "---------"),
            ("2", "Yes"),
            ("3", "No"),
        )
        self.attrs["class"] = "nautobot-select2-static"

nautobot.apps.forms.BulkRenameForm

Bases: forms.Form

An extendable form to be used for renaming objects in bulk.

Source code in nautobot/core/forms/forms.py
class BulkRenameForm(forms.Form):
    """
    An extendable form to be used for renaming objects in bulk.
    """

    find = forms.CharField()
    replace = forms.CharField()
    use_regex = forms.BooleanField(required=False, initial=True, label="Use regular expressions")

    def clean(self):
        super().clean()

        # Validate regular expression in "find" field
        if self.cleaned_data["use_regex"]:
            try:
                re.compile(self.cleaned_data["find"])
            except re.error:
                raise forms.ValidationError({"find": "Invalid regular expression"})

nautobot.apps.forms.CSVChoiceField

Bases: django_forms.ChoiceField

Invert the provided set of choices to take the human-friendly label as input, and return the database value.

Despite the name, this is no longer used in CSV imports since 2.0, but is used in JSON/YAML import of DeviceTypes.

Source code in nautobot/core/forms/fields.py
class CSVChoiceField(django_forms.ChoiceField):
    """
    Invert the provided set of choices to take the human-friendly label as input, and return the database value.

    Despite the name, this is no longer used in CSV imports since 2.0, but *is* used in JSON/YAML import of DeviceTypes.
    """

    STATIC_CHOICES = True

    def __init__(self, *, choices=(), **kwargs):
        super().__init__(choices=choices, **kwargs)
        self.choices = core_choices.unpack_grouped_choices(choices)

nautobot.apps.forms.CSVContentTypeField

Bases: CSVModelChoiceField

Reference a ContentType in the form {app_label}.{model}.

Note: class name is misleading; this field is also used in numerous FilterSets where it has nothing to do with CSV.

Source code in nautobot/core/forms/fields.py
class CSVContentTypeField(CSVModelChoiceField):
    """
    Reference a ContentType in the form `{app_label}.{model}`.

    Note: class name is misleading; this field is also used in numerous FilterSets where it has nothing to do with CSV.
    """

    STATIC_CHOICES = True

    def prepare_value(self, value):
        """
        Allow this field to support `{app_label}.{model}` style, null values, or PK-based lookups
        depending on how the field is used.
        """
        if value is None:
            return ""

        # Only pass through strings if they aren't numeric. Otherwise cast to `int`.
        if isinstance(value, str):
            if not value.isdigit():
                return value
            else:
                value = int(value)

        # Integers are PKs
        if isinstance(value, int):
            value = self.queryset.get(pk=value)

        return f"{value.app_label}.{value.model}"

    def to_python(self, value):
        value = self.prepare_value(value)
        try:
            app_label, model = value.split(".")
        except ValueError:
            raise ValidationError('Object type must be specified as "<app_label>.<model>"')
        try:
            return self.queryset.get(app_label=app_label, model=model)
        except ObjectDoesNotExist:
            raise ValidationError("Invalid object type")

prepare_value(value)

Allow this field to support {app_label}.{model} style, null values, or PK-based lookups depending on how the field is used.

Source code in nautobot/core/forms/fields.py
def prepare_value(self, value):
    """
    Allow this field to support `{app_label}.{model}` style, null values, or PK-based lookups
    depending on how the field is used.
    """
    if value is None:
        return ""

    # Only pass through strings if they aren't numeric. Otherwise cast to `int`.
    if isinstance(value, str):
        if not value.isdigit():
            return value
        else:
            value = int(value)

    # Integers are PKs
    if isinstance(value, int):
        value = self.queryset.get(pk=value)

    return f"{value.app_label}.{value.model}"

nautobot.apps.forms.CSVDataField

Bases: django_forms.CharField

A CharField (rendered as a Textarea) which expects CSV-formatted data.

Initial value is a list of headers corresponding to the required fields for the given serializer class.

This no longer actually does any CSV parsing or validation on its own, as that is now handled by the NautobotCSVParser class and the REST API serializers.

Parameters:

Name Type Description Default
required_field_names list[str]

List of field names representing required fields for this import.

''
Source code in nautobot/core/forms/fields.py
class CSVDataField(django_forms.CharField):
    """
    A CharField (rendered as a Textarea) which expects CSV-formatted data.

    Initial value is a list of headers corresponding to the required fields for the given serializer class.

    This no longer actually does any CSV parsing or validation on its own,
    as that is now handled by the NautobotCSVParser class and the REST API serializers.

    Args:
        required_field_names (list[str]): List of field names representing required fields for this import.
    """

    widget = django_forms.Textarea

    def __init__(self, *args, required_field_names="", **kwargs):
        self.required_field_names = required_field_names
        kwargs.setdefault("required", False)

        super().__init__(*args, **kwargs)

        self.strip = False
        if not self.label:
            self.label = ""
        if not self.initial:
            self.initial = ",".join(self.required_field_names) + "\n"
        if not self.help_text:
            self.help_text = (
                "Enter the list of column headers followed by one line per record to be imported, using "
                "commas to separate values. Multi-line data and values containing commas may be wrapped "
                "in double quotes."
            )

nautobot.apps.forms.CSVFileField

Bases: django_forms.FileField

A FileField (rendered as a ClearableFileInput) which expects a file containing CSV-formatted data.

This no longer actually does any CSV parsing or validation on its own, as that is now handled by the NautobotCSVParser class and the REST API serializers.

Source code in nautobot/core/forms/fields.py
class CSVFileField(django_forms.FileField):
    """
    A FileField (rendered as a ClearableFileInput) which expects a file containing CSV-formatted data.

    This no longer actually does any CSV parsing or validation on its own,
    as that is now handled by the NautobotCSVParser class and the REST API serializers.
    """

    def __init__(self, *args, **kwargs):
        kwargs.setdefault("required", False)

        super().__init__(*args, **kwargs)

        if not self.label:
            self.label = "CSV File"
        if not self.help_text:
            self.help_text = (
                "Select a CSV file to upload. It should contain column headers in the first row and use commas "
                "to separate values. Multi-line data and values containing commas may be wrapped "
                "in double quotes."
            )

    def to_python(self, file):
        """For parity with CSVDataField, this returns the CSV text rather than an UploadedFile object."""
        if file is None:
            return None

        file = super().to_python(file)
        return file.read().decode("utf-8-sig").strip()

to_python(file)

For parity with CSVDataField, this returns the CSV text rather than an UploadedFile object.

Source code in nautobot/core/forms/fields.py
def to_python(self, file):
    """For parity with CSVDataField, this returns the CSV text rather than an UploadedFile object."""
    if file is None:
        return None

    file = super().to_python(file)
    return file.read().decode("utf-8-sig").strip()

nautobot.apps.forms.CSVModelChoiceField

Bases: django_forms.ModelChoiceField

Provides additional validation for model choices entered as CSV data.

Note: class name is misleading; the subclass CSVContentTypeField (below) is also used in FilterSets, where it has nothing to do with CSV data.

Source code in nautobot/core/forms/fields.py
class CSVModelChoiceField(django_forms.ModelChoiceField):
    """
    Provides additional validation for model choices entered as CSV data.

    Note: class name is misleading; the subclass CSVContentTypeField (below) is also used in FilterSets, where it has
    nothing to do with CSV data.
    """

    default_error_messages = {
        "invalid_choice": "Object not found.",
    }

    def to_python(self, value):
        try:
            return super().to_python(value)
        except MultipleObjectsReturned:
            raise ValidationError(f'"{value}" is not a unique value for this field; multiple objects were found')

nautobot.apps.forms.CSVModelForm

Bases: forms.ModelForm

ModelForm used for the import of objects.

Note: the name is misleading as since 2.0 this is no longer used for CSV imports; however it is still used for JSON/YAML imports of DeviceTypes and their component templates.

Source code in nautobot/core/forms/forms.py
class CSVModelForm(forms.ModelForm):
    """
    ModelForm used for the import of objects.

    Note: the name is misleading as since 2.0 this is no longer used for CSV imports; however it *is* still used for
    JSON/YAML imports of DeviceTypes and their component templates.
    """

    def __init__(self, *args, headers=None, **kwargs):
        super().__init__(*args, **kwargs)

        # Modify the model form to accommodate any customized to_field_name properties
        if headers:
            for field, to_field in headers.items():
                if to_field is not None:
                    self.fields[field].to_field_name = to_field

nautobot.apps.forms.CSVMultipleChoiceField

Bases: CSVChoiceField

A version of CSVChoiceField that supports and emits a list of choice values.

As with CSVChoiceField, the name is misleading, as this is no longer used for CSV imports, but is used for JSON/YAML import of DeviceTypes still.

Source code in nautobot/core/forms/fields.py
class CSVMultipleChoiceField(CSVChoiceField):
    """
    A version of CSVChoiceField that supports and emits a list of choice values.

    As with CSVChoiceField, the name is misleading, as this is no longer used for CSV imports, but is used for
    JSON/YAML import of DeviceTypes still.
    """

    def to_python(self, value):
        """Return a list of strings."""
        if value in self.empty_values:
            return ""
        return [v.strip() for v in str(value).split(",")]

    def validate(self, value):
        """Validate that each of the input values is in self.choices."""
        for v in value:
            super().validate(v)

to_python(value)

Return a list of strings.

Source code in nautobot/core/forms/fields.py
def to_python(self, value):
    """Return a list of strings."""
    if value in self.empty_values:
        return ""
    return [v.strip() for v in str(value).split(",")]

validate(value)

Validate that each of the input values is in self.choices.

Source code in nautobot/core/forms/fields.py
def validate(self, value):
    """Validate that each of the input values is in self.choices."""
    for v in value:
        super().validate(v)

nautobot.apps.forms.CSVMultipleContentTypeField

Bases: MultipleContentTypeField

Reference a list of ContentType objects in the form `{app_label}.{model}'.

Note: This is unused in Nautobot core at this time, but some apps (data-validation-engine) use this for non-CSV purposes, similar to CSVContentTypeField above.

Source code in nautobot/core/forms/fields.py
class CSVMultipleContentTypeField(MultipleContentTypeField):
    """
    Reference a list of `ContentType` objects in the form `{app_label}.{model}'.

    Note: This is unused in Nautobot core at this time, but some apps (data-validation-engine) use this for non-CSV
    purposes, similar to CSVContentTypeField above.
    """

    def prepare_value(self, value):
        """Parse a comma-separated string of model names into a list of PKs."""
        # "".split(",") yields [""] rather than [], which we don't want!
        if isinstance(value, str) and value:
            value = value.split(",")

        # For each model name, retrieve the model object and extract its
        # content-type PK.
        pk_list = []
        if isinstance(value, (list, tuple)):
            for v in value:
                try:
                    model = apps.get_model(v)
                except (ValueError, LookupError):
                    raise ValidationError(
                        self.error_messages["invalid_choice"],
                        code="invalid_choice",
                        params={"value": v},
                    )
                ct = self.queryset.model.objects.get_for_model(model)
                pk_list.append(ct.pk)

        return super().prepare_value(pk_list)

prepare_value(value)

Parse a comma-separated string of model names into a list of PKs.

Source code in nautobot/core/forms/fields.py
def prepare_value(self, value):
    """Parse a comma-separated string of model names into a list of PKs."""
    # "".split(",") yields [""] rather than [], which we don't want!
    if isinstance(value, str) and value:
        value = value.split(",")

    # For each model name, retrieve the model object and extract its
    # content-type PK.
    pk_list = []
    if isinstance(value, (list, tuple)):
        for v in value:
            try:
                model = apps.get_model(v)
            except (ValueError, LookupError):
                raise ValidationError(
                    self.error_messages["invalid_choice"],
                    code="invalid_choice",
                    params={"value": v},
                )
            ct = self.queryset.model.objects.get_for_model(model)
            pk_list.append(ct.pk)

    return super().prepare_value(pk_list)

nautobot.apps.forms.ColorSelect

Bases: forms.Select

Extends the built-in Select widget to colorize each

Source code in nautobot/core/forms/widgets.py
class ColorSelect(forms.Select):
    """
    Extends the built-in Select widget to colorize each <option>.
    """

    option_template_name = "widgets/colorselect_option.html"

    def __init__(self, *args, **kwargs):
        kwargs["choices"] = utils.add_blank_choice(choices.ColorChoices)
        super().__init__(*args, **kwargs)
        self.attrs["class"] = "nautobot-select2-color-picker"

nautobot.apps.forms.CommentField

Bases: django_forms.CharField

A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.

Source code in nautobot/core/forms/fields.py
class CommentField(django_forms.CharField):
    """
    A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text.
    """

    widget = django_forms.Textarea
    default_label = ""
    # TODO: Port Markdown cheat sheet to internal documentation
    default_helptext = (
        '<i class="mdi mdi-information-outline"></i> '
        '<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank">'
        "Markdown</a> syntax is supported"
    )

    def __init__(self, *args, **kwargs):
        required = kwargs.pop("required", False)
        label = kwargs.pop("label", self.default_label)
        help_text = kwargs.pop("help_text", self.default_helptext)
        super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs)

nautobot.apps.forms.ConfirmationForm

Bases: BootstrapMixin, ReturnURLForm

A generic confirmation form. The form is not valid unless the confirm field is checked.

Source code in nautobot/core/forms/forms.py
class ConfirmationForm(BootstrapMixin, ReturnURLForm):
    """
    A generic confirmation form. The form is not valid unless the confirm field is checked.
    """

    confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)

nautobot.apps.forms.ContentTypeSelect

Bases: StaticSelect2

Appends an api-value attribute equal to the slugified model name for each ContentType. For example: This attribute can be used to reference the relevant API endpoint for a particular ContentType.

Source code in nautobot/core/forms/widgets.py
class ContentTypeSelect(StaticSelect2):
    """
    Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example:
        <option value="37" api-value="console-server-port">console server port</option>
    This attribute can be used to reference the relevant API endpoint for a particular ContentType.
    """

    option_template_name = "widgets/select_contenttype.html"

nautobot.apps.forms.CustomFieldModelCSVForm

Bases: CSVModelForm, CustomFieldModelFormMixin

Base class for CSV/JSON/YAML import of models that support custom fields.

TODO: The class name is a misnomer; as of 2.0 this class is not used for any CSV imports, as that's now handled by the REST API. However it is still used when importing component-templates as part of a JSON/YAML DeviceType import.

Source code in nautobot/extras/forms/forms.py
class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelFormMixin):
    """
    Base class for CSV/JSON/YAML import of models that support custom fields.

    TODO: The class name is a misnomer; as of 2.0 this class is **not** used for any CSV imports,
    as that's now handled by the REST API. However it is still used when importing component-templates as
    part of a JSON/YAML DeviceType import.
    """

    def _append_customfield_fields(self):
        # Append form fields
        for cf in CustomField.objects.filter(content_types=self.obj_type):
            field_name = cf.add_prefix_to_cf_key()
            self.fields[field_name] = cf.to_form_field(for_csv_import=True)

            # Annotate the field in the list of CustomField form fields
            self.custom_fields.append(field_name)

nautobot.apps.forms.CustomFieldModelFormMixin

Bases: forms.ModelForm

Source code in nautobot/extras/forms/mixins.py
class CustomFieldModelFormMixin(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        self.obj_type = ContentType.objects.get_for_model(self._meta.model)
        self.custom_fields = []

        super().__init__(*args, **kwargs)

        self._append_customfield_fields()
        try:
            if self.Meta.fields == "__all__":
                # Above we accounted for all cf_* data, so can safely remove _custom_field_data.
                self.fields.pop("_custom_field_data", None)
        except AttributeError:
            pass

    def _append_customfield_fields(self):
        """
        Append form fields for all CustomFields assigned to this model.
        """
        # Append form fields; assign initial values if modifying and existing object
        for cf in CustomField.objects.filter(content_types=self.obj_type):
            field_name = cf.add_prefix_to_cf_key()
            if self.instance.present_in_database:
                self.fields[field_name] = cf.to_form_field(set_initial=False)
                self.fields[field_name].initial = self.instance.cf.get(cf.key)
            else:
                self.fields[field_name] = cf.to_form_field()

            # Annotate the field in the list of CustomField form fields
            self.custom_fields.append(field_name)

    def clean(self):
        # Save custom field data on instance
        for field_name in self.custom_fields:
            self.instance.cf[remove_prefix_from_cf_key(field_name)] = self.cleaned_data.get(field_name)

        return super().clean()

nautobot.apps.forms.DatePicker

Bases: forms.TextInput

Date picker using Flatpickr.

Source code in nautobot/core/forms/widgets.py
class DatePicker(forms.TextInput):
    """
    Date picker using Flatpickr.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.attrs["class"] = "date-picker"
        self.attrs["placeholder"] = "YYYY-MM-DD"

nautobot.apps.forms.DateTimePicker

Bases: forms.TextInput

DateTime picker using Flatpickr.

Source code in nautobot/core/forms/widgets.py
class DateTimePicker(forms.TextInput):
    """
    DateTime picker using Flatpickr.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.attrs["class"] = "datetime-picker"
        self.attrs["placeholder"] = "YYYY-MM-DD hh:mm:ss"

nautobot.apps.forms.DynamicFilterForm

Bases: BootstrapMixin, forms.Form

Form for dynamically inputting filter values for an object list.

Source code in nautobot/core/forms/forms.py
class DynamicFilterForm(BootstrapMixin, forms.Form):
    """
    Form for dynamically inputting filter values for an object list.
    """

    lookup_field = forms.ChoiceField(
        choices=[],
        required=False,
        label="Field",
    )
    lookup_type = forms.ChoiceField(
        choices=[],
        required=False,
    )
    lookup_value = forms.CharField(
        required=False,
        label="Value",
    )

    def __init__(self, *args, filterset=None, **kwargs):
        super().__init__(*args, **kwargs)
        from nautobot.core.forms import add_blank_choice  # Avoid circular import

        # cls.model is set at `dynamic_formset_factory()`
        self.filterset = filterset or getattr(self, "filterset", None)

        # Raise exception if `cls.filterset` not set and `filterset` not passed
        if self.filterset is None:
            raise AttributeError("'DynamicFilterForm' object requires `filterset` attribute")

        model = self.filterset._meta.model

        if self.filterset is not None:
            self.filterset_filters = self.filterset.filters
            contenttype = model._meta.app_label + "." + model._meta.model_name

            # Configure fields: Add css class and set choices for lookup_field
            self.fields["lookup_field"].choices = add_blank_choice(self._get_lookup_field_choices())
            self.fields["lookup_field"].widget.attrs["class"] = "nautobot-select2-static lookup_field-select"

            # Update lookup_type and lookup_value fields to match expected field types derived from data
            # e.g status expects a ChoiceField with APISelectMultiple widget, while name expects a CharField etc.
            if "data" in kwargs and "prefix" in kwargs:
                data = kwargs["data"]
                prefix = kwargs["prefix"]
                lookup_type = data.get(prefix + "-lookup_type")
                lookup_value = data.getlist(prefix + "-lookup_value")

                if lookup_type and lookup_value and lookup_type in self.filterset_filters:
                    verbose_name = self.filterset_filters[lookup_type].lookup_expr
                    label = build_lookup_label(lookup_type, verbose_name)
                    self.fields["lookup_type"].choices = [(lookup_type, label)]
                    self.fields["lookup_value"] = get_filterset_parameter_form_field(
                        model, lookup_type, filterset=self.filterset
                    )
                elif lookup_type and lookup_type not in self.filterset_filters:
                    logger.warning(f"{lookup_type} is not a valid {self.filterset.__class__.__name__} field")

            self.fields["lookup_type"].widget.attrs["data-query-param-field_name"] = json.dumps(["$lookup_field"])
            self.fields["lookup_type"].widget.attrs["data-contenttype"] = contenttype
            self.fields["lookup_type"].widget.attrs["data-url"] = reverse("core-api:filtersetfield-list-lookupchoices")
            self.fields["lookup_type"].widget.attrs["class"] = "nautobot-select2-api lookup_type-select"

            lookup_value_css = self.fields["lookup_value"].widget.attrs.get("class") or ""
            self.fields["lookup_value"].widget.attrs["class"] = " ".join(
                [lookup_value_css, "lookup_value-input form-control"]
            )
        else:
            logger.warning(f"FilterSet for {model.__class__} not found.")

    def _get_lookup_field_choices(self):
        """Get choices for lookup_fields i.e filterset parameters without a lookup expr"""
        from nautobot.extras.filters.mixins import RelationshipFilter  # Avoid circular import

        filterset_without_lookup = (
            (
                name,
                get_filter_field_label(filter_field),
            )
            for name, filter_field in self.filterset_filters.items()
            if isinstance(filter_field, RelationshipFilter) or ("__" not in name and name != "q")
        )
        return sorted(filterset_without_lookup, key=lambda x: x[1])

__init__(*args, filterset=None, **kwargs)

Source code in nautobot/core/forms/forms.py
def __init__(self, *args, filterset=None, **kwargs):
    super().__init__(*args, **kwargs)
    from nautobot.core.forms import add_blank_choice  # Avoid circular import

    # cls.model is set at `dynamic_formset_factory()`
    self.filterset = filterset or getattr(self, "filterset", None)

    # Raise exception if `cls.filterset` not set and `filterset` not passed
    if self.filterset is None:
        raise AttributeError("'DynamicFilterForm' object requires `filterset` attribute")

    model = self.filterset._meta.model

    if self.filterset is not None:
        self.filterset_filters = self.filterset.filters
        contenttype = model._meta.app_label + "." + model._meta.model_name

        # Configure fields: Add css class and set choices for lookup_field
        self.fields["lookup_field"].choices = add_blank_choice(self._get_lookup_field_choices())
        self.fields["lookup_field"].widget.attrs["class"] = "nautobot-select2-static lookup_field-select"

        # Update lookup_type and lookup_value fields to match expected field types derived from data
        # e.g status expects a ChoiceField with APISelectMultiple widget, while name expects a CharField etc.
        if "data" in kwargs and "prefix" in kwargs:
            data = kwargs["data"]
            prefix = kwargs["prefix"]
            lookup_type = data.get(prefix + "-lookup_type")
            lookup_value = data.getlist(prefix + "-lookup_value")

            if lookup_type and lookup_value and lookup_type in self.filterset_filters:
                verbose_name = self.filterset_filters[lookup_type].lookup_expr
                label = build_lookup_label(lookup_type, verbose_name)
                self.fields["lookup_type"].choices = [(lookup_type, label)]
                self.fields["lookup_value"] = get_filterset_parameter_form_field(
                    model, lookup_type, filterset=self.filterset
                )
            elif lookup_type and lookup_type not in self.filterset_filters:
                logger.warning(f"{lookup_type} is not a valid {self.filterset.__class__.__name__} field")

        self.fields["lookup_type"].widget.attrs["data-query-param-field_name"] = json.dumps(["$lookup_field"])
        self.fields["lookup_type"].widget.attrs["data-contenttype"] = contenttype
        self.fields["lookup_type"].widget.attrs["data-url"] = reverse("core-api:filtersetfield-list-lookupchoices")
        self.fields["lookup_type"].widget.attrs["class"] = "nautobot-select2-api lookup_type-select"

        lookup_value_css = self.fields["lookup_value"].widget.attrs.get("class") or ""
        self.fields["lookup_value"].widget.attrs["class"] = " ".join(
            [lookup_value_css, "lookup_value-input form-control"]
        )
    else:
        logger.warning(f"FilterSet for {model.__class__} not found.")

nautobot.apps.forms.DynamicModelChoiceField

Bases: DynamicModelChoiceMixin, django_forms.ModelChoiceField

Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.

Source code in nautobot/core/forms/fields.py
class DynamicModelChoiceField(DynamicModelChoiceMixin, django_forms.ModelChoiceField):
    """
    Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be
    rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget.
    """

    def clean(self, value):
        """
        When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
        string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
        """
        if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
            return None
        return super().clean(value)

clean(value)

When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.

Source code in nautobot/core/forms/fields.py
def clean(self, value):
    """
    When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
    string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
    """
    if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
        return None
    return super().clean(value)

nautobot.apps.forms.DynamicModelChoiceMixin

:param display_field: The name of the attribute of an API response object to display in the selection list :param query_params: A dictionary of additional key/value pairs to attach to the API request :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value :param null_option: The string used to represent a null selection (if any) :param disabled_indicator: The name of the field which, if populated, will disable selection of the choice (optional) :param depth: Nested serialization depth when making API requests (default: 0 or a flat representation)

Source code in nautobot/core/forms/fields.py
class DynamicModelChoiceMixin:
    """
    :param display_field: The name of the attribute of an API response object to display in the selection list
    :param query_params: A dictionary of additional key/value pairs to attach to the API request
    :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value
    :param null_option: The string used to represent a null selection (if any)
    :param disabled_indicator: The name of the field which, if populated, will disable selection of the
        choice (optional)
    :param depth: Nested serialization depth when making API requests (default: `0` or a flat representation)
    """

    filter = django_filters.ModelChoiceFilter  # 2.0 TODO(Glenn): can we rename this? pylint: disable=redefined-builtin
    widget = widgets.APISelect

    def __init__(
        self,
        display_field="display",
        query_params=None,
        initial_params=None,
        null_option=None,
        disabled_indicator=None,
        depth=0,
        *args,
        **kwargs,
    ):
        self.display_field = display_field
        self.query_params = query_params or {}
        self.initial_params = initial_params or {}
        self.null_option = null_option
        self.disabled_indicator = disabled_indicator
        self.depth = depth

        # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
        # by widget_attrs()
        self.to_field_name = kwargs.get("to_field_name")

        super().__init__(*args, **kwargs)

    def widget_attrs(self, widget):
        attrs = {
            "display-field": self.display_field,
        }

        # Set value-field attribute if the field specifies to_field_name
        if self.to_field_name:
            attrs["value-field"] = self.to_field_name

        # Set the string used to represent a null option
        if self.null_option is not None:
            attrs["data-null-option"] = self.null_option

        # Set the disabled indicator, if any
        if self.disabled_indicator is not None:
            attrs["disabled-indicator"] = self.disabled_indicator

        # Toggle depth
        attrs["data-depth"] = self.depth

        # Attach any static query parameters
        for key, value in self.query_params.items():
            widget.add_query_param(key, value)

        return attrs

    def prepare_value(self, value):
        """
        Augment the behavior of forms.ModelChoiceField.prepare_value().

        Specifically, if `value` is a PK, but we have `to_field_name` set, we need to look up the model instance
        from the given PK, so that the base class will get the appropriate field value rather than just keeping the PK,
        because the rendered form field needs this in order to correctly prepopulate a default selection.
        """
        if self.to_field_name and data_utils.is_uuid(value):
            try:
                value = self.queryset.get(pk=value)
            except ObjectDoesNotExist:
                pass
        return super().prepare_value(value)

    def get_bound_field(self, form, field_name):
        bound_field = BoundField(form, self, field_name)

        # Set initial value based on prescribed child fields (if not already set)
        if not self.initial and self.initial_params:
            filter_kwargs = {}
            for kwarg, child_field in self.initial_params.items():
                value = form.initial.get(child_field.lstrip("$"))
                if value:
                    filter_kwargs[kwarg] = value
            if filter_kwargs:
                self.initial = self.queryset.filter(**filter_kwargs).first()

        # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
        # will be populated on-demand via the APISelect widget.
        data = bound_field.value()
        if data:
            field_name = getattr(self, "to_field_name") or "pk"
            filter_ = self.filter(field_name=field_name)
            try:
                self.queryset = filter_.filter(self.queryset, data)
            except (TypeError, ValidationError):
                # Catch any error caused by invalid initial data passed from the user
                self.queryset = self.queryset.none()
        else:
            self.queryset = self.queryset.none()

        # Set the data URL on the APISelect widget (if not already set)
        widget = bound_field.field.widget
        if not widget.attrs.get("data-url"):
            route = lookup.get_route_for_model(self.queryset.model, "list", api=True)
            data_url = reverse(route)
            widget.attrs["data-url"] = data_url

        return bound_field

prepare_value(value)

Augment the behavior of forms.ModelChoiceField.prepare_value().

Specifically, if value is a PK, but we have to_field_name set, we need to look up the model instance from the given PK, so that the base class will get the appropriate field value rather than just keeping the PK, because the rendered form field needs this in order to correctly prepopulate a default selection.

Source code in nautobot/core/forms/fields.py
def prepare_value(self, value):
    """
    Augment the behavior of forms.ModelChoiceField.prepare_value().

    Specifically, if `value` is a PK, but we have `to_field_name` set, we need to look up the model instance
    from the given PK, so that the base class will get the appropriate field value rather than just keeping the PK,
    because the rendered form field needs this in order to correctly prepopulate a default selection.
    """
    if self.to_field_name and data_utils.is_uuid(value):
        try:
            value = self.queryset.get(pk=value)
        except ObjectDoesNotExist:
            pass
    return super().prepare_value(value)

nautobot.apps.forms.DynamicModelMultipleChoiceField

Bases: DynamicModelChoiceMixin, django_forms.ModelMultipleChoiceField

A multiple-choice version of DynamicModelChoiceField.

Source code in nautobot/core/forms/fields.py
class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, django_forms.ModelMultipleChoiceField):
    """
    A multiple-choice version of DynamicModelChoiceField.
    """

    filter = django_filters.ModelMultipleChoiceFilter
    widget = widgets.APISelectMultiple

nautobot.apps.forms.ExpandableIPAddressField

Bases: django_forms.CharField

A field which allows for expansion of IP address ranges Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']

Source code in nautobot/core/forms/fields.py
class ExpandableIPAddressField(django_forms.CharField):
    """
    A field which allows for expansion of IP address ranges
      Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24']
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not self.help_text:
            self.help_text = (
                "Specify a numeric range to create multiple IPs.<br />Example: <code>192.0.2.[1,5,100-254]/24</code>"
            )

    def to_python(self, value):
        # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
        if len(value.split("/")) != 2:
            raise ValidationError("CIDR mask (e.g. /24) is required.")

        # Hackish address version detection but it's all we have to work with
        if "." in value and re.search(forms.IP4_EXPANSION_PATTERN, value):
            return list(forms.expand_ipaddress_pattern(value, 4))
        elif ":" in value and re.search(forms.IP6_EXPANSION_PATTERN, value):
            return list(forms.expand_ipaddress_pattern(value, 6))

        return [value]

nautobot.apps.forms.ExpandableNameField

Bases: django_forms.CharField

A field which allows for numeric range expansion Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']

Source code in nautobot/core/forms/fields.py
class ExpandableNameField(django_forms.CharField):
    """
    A field which allows for numeric range expansion
      Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3']
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not self.help_text:
            self.help_text = """
                Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
                are not supported. Examples:
                <ul>
                    <li><code>[ge,xe]-0/0/[0-9]</code></li>
                    <li><code>e[0-3][a-d,f]</code></li>
                </ul>
                """

    def to_python(self, value):
        if not value:
            return ""
        if re.search(forms.ALPHANUMERIC_EXPANSION_PATTERN, value):
            return list(forms.expand_alphanumeric_pattern(value))
        return [value]

nautobot.apps.forms.ImportForm

Bases: BootstrapMixin, forms.Form

Generic form for creating an object from JSON/YAML data

Source code in nautobot/core/forms/forms.py
class ImportForm(BootstrapMixin, forms.Form):
    """
    Generic form for creating an object from JSON/YAML data
    """

    data = forms.CharField(
        widget=forms.Textarea,
        help_text="Enter object data in JSON or YAML format. Note: Only a single object/document is supported.",
        label="",
    )
    format = forms.ChoiceField(choices=(("json", "JSON"), ("yaml", "YAML")), initial="yaml")

    def clean(self):
        super().clean()

        data = self.cleaned_data["data"]
        format_ = self.cleaned_data["format"]

        # Process JSON/YAML data
        if format_ == "json":
            try:
                self.cleaned_data["data"] = json.loads(data)
                # Check for multiple JSON objects
                if not isinstance(self.cleaned_data["data"], dict):
                    raise forms.ValidationError({"data": "Import is limited to one object at a time."})
            except json.decoder.JSONDecodeError as err:
                raise forms.ValidationError({"data": f"Invalid JSON data: {err}"})
        else:
            # Check for multiple YAML documents
            if "\n---" in data:
                raise forms.ValidationError({"data": "Import is limited to one object at a time."})
            try:
                self.cleaned_data["data"] = yaml.load(data, Loader=yaml.SafeLoader)
            except yaml.error.YAMLError as err:
                raise forms.ValidationError({"data": f"Invalid YAML data: {err}"})

nautobot.apps.forms.JSONArrayFormField

Bases: django_forms.JSONField

A FormField counterpart to JSONArrayField. Replicates ArrayFormField's base field validation: Field values are validated as JSON Arrays, and each Array element is validated by base_field validators.

Source code in nautobot/core/forms/fields.py
class JSONArrayFormField(django_forms.JSONField):
    """
    A FormField counterpart to JSONArrayField.
    Replicates ArrayFormField's base field validation: Field values are validated as JSON Arrays,
    and each Array element is validated by `base_field` validators.
    """

    def __init__(self, base_field, *, delimiter=",", **kwargs):
        self.base_field = base_field
        self.delimiter = delimiter
        super().__init__(**kwargs)

    def clean(self, value):
        """
        Validate `value` and return its "cleaned" value as an appropriate
        Python object. Raise ValidationError for any errors.
        """
        value = super().clean(value)
        return [self.base_field.clean(val) for val in value]

    def prepare_value(self, value):
        """
        Return a string of this value.
        """
        if isinstance(value, list):
            return self.delimiter.join(str(self.base_field.prepare_value(v)) for v in value)
        return value

    def to_python(self, value):
        """
        Convert `value` into JSON, raising django.core.exceptions.ValidationError
        if the data can't be converted. Return the converted value.
        """
        if isinstance(value, list):
            items = value
        elif value:
            try:
                items = value.split(self.delimiter)
            except Exception as e:
                raise ValidationError(e)
        else:
            items = []

        errors = []
        values = []
        for item in items:
            try:
                values.append(self.base_field.to_python(item))
            except ValidationError as error:
                errors.append(error)
        if errors:
            raise ValidationError(errors)
        return values

    def validate(self, value):
        """
        Validate `value` and raise ValidationError if necessary.
        """
        super().validate(value)
        errors = []
        for item in value:
            try:
                self.base_field.validate(item)
            except ValidationError as error:
                errors.append(error)
        if errors:
            raise ValidationError(errors)

    def run_validators(self, value):
        """
        Runs all validators against `value` and raise ValidationError if necessary.
        Some validators can't be created at field initialization time.
        """
        super().run_validators(value)
        errors = []
        for item in value:
            try:
                self.base_field.run_validators(item)
            except ValidationError as error:
                errors.append(error)
        if errors:
            raise ValidationError(errors)

    def has_changed(self, initial, data):
        """
        Return True if `data` differs from `initial`.
        """
        value = self.to_python(data)
        if initial in self.empty_values and value in self.empty_values:
            return False
        return super().has_changed(initial, data)

clean(value)

Validate value and return its "cleaned" value as an appropriate Python object. Raise ValidationError for any errors.

Source code in nautobot/core/forms/fields.py
def clean(self, value):
    """
    Validate `value` and return its "cleaned" value as an appropriate
    Python object. Raise ValidationError for any errors.
    """
    value = super().clean(value)
    return [self.base_field.clean(val) for val in value]

has_changed(initial, data)

Return True if data differs from initial.

Source code in nautobot/core/forms/fields.py
def has_changed(self, initial, data):
    """
    Return True if `data` differs from `initial`.
    """
    value = self.to_python(data)
    if initial in self.empty_values and value in self.empty_values:
        return False
    return super().has_changed(initial, data)

prepare_value(value)

Return a string of this value.

Source code in nautobot/core/forms/fields.py
def prepare_value(self, value):
    """
    Return a string of this value.
    """
    if isinstance(value, list):
        return self.delimiter.join(str(self.base_field.prepare_value(v)) for v in value)
    return value

run_validators(value)

Runs all validators against value and raise ValidationError if necessary. Some validators can't be created at field initialization time.

Source code in nautobot/core/forms/fields.py
def run_validators(self, value):
    """
    Runs all validators against `value` and raise ValidationError if necessary.
    Some validators can't be created at field initialization time.
    """
    super().run_validators(value)
    errors = []
    for item in value:
        try:
            self.base_field.run_validators(item)
        except ValidationError as error:
            errors.append(error)
    if errors:
        raise ValidationError(errors)

to_python(value)

Convert value into JSON, raising django.core.exceptions.ValidationError if the data can't be converted. Return the converted value.

Source code in nautobot/core/forms/fields.py
def to_python(self, value):
    """
    Convert `value` into JSON, raising django.core.exceptions.ValidationError
    if the data can't be converted. Return the converted value.
    """
    if isinstance(value, list):
        items = value
    elif value:
        try:
            items = value.split(self.delimiter)
        except Exception as e:
            raise ValidationError(e)
    else:
        items = []

    errors = []
    values = []
    for item in items:
        try:
            values.append(self.base_field.to_python(item))
        except ValidationError as error:
            errors.append(error)
    if errors:
        raise ValidationError(errors)
    return values

validate(value)

Validate value and raise ValidationError if necessary.

Source code in nautobot/core/forms/fields.py
def validate(self, value):
    """
    Validate `value` and raise ValidationError if necessary.
    """
    super().validate(value)
    errors = []
    for item in value:
        try:
            self.base_field.validate(item)
        except ValidationError as error:
            errors.append(error)
    if errors:
        raise ValidationError(errors)

nautobot.apps.forms.JSONField

Bases: _JSONField

Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.

Source code in nautobot/core/forms/fields.py
class JSONField(_JSONField):
    """
    Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if not self.help_text:
            self.help_text = 'Enter context data in <a href="https://json.org/">JSON</a> format.'
            self.widget.attrs["placeholder"] = ""

    def prepare_value(self, value):
        if isinstance(value, InvalidJSONInput):
            return value
        if value is None:
            return ""
        return json.dumps(value, sort_keys=True, indent=4, ensure_ascii=False)

    # TODO: remove this when we upgrade to Django 4
    def bound_data(self, data, initial):
        if data is None:
            return None
        return super().bound_data(data, initial)

nautobot.apps.forms.LaxURLField

Bases: django_forms.URLField

Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)

Source code in nautobot/core/forms/fields.py
class LaxURLField(django_forms.URLField):
    """
    Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
    (e.g. http://myserver/ is valid)
    """

    default_validators = [validators.EnhancedURLValidator()]

nautobot.apps.forms.MultiMatchModelMultipleChoiceField

Bases: DynamicModelChoiceMixin, django_filters.fields.ModelMultipleChoiceField

Filter field to support matching on the PK or to_field_name fields (defaulting to slug if not specified).

Raises ValidationError if none of the fields match the requested value.

Source code in nautobot/core/forms/fields.py
class MultiMatchModelMultipleChoiceField(DynamicModelChoiceMixin, django_filters.fields.ModelMultipleChoiceField):
    """
    Filter field to support matching on the PK *or* `to_field_name` fields (defaulting to `slug` if not specified).

    Raises ValidationError if none of the fields match the requested value.
    """

    filter = django_filters.ModelMultipleChoiceFilter
    widget = widgets.APISelectMultiple

    def __init__(self, *args, **kwargs):
        self.natural_key = kwargs.setdefault("to_field_name", "slug")
        super().__init__(*args, **kwargs)

    def _check_values(self, values):
        """
        This method overloads the grandparent method in `django.forms.models.ModelMultipleChoiceField`,
        re-using some of that method's existing logic and adding support for coupling this field with
        multiple model fields.
        """
        null = self.null_label is not None and values and self.null_value in values
        if null:
            values = [v for v in values if v != self.null_value]
        # deduplicate given values to avoid creating many querysets or
        # requiring the database backend deduplicate efficiently.
        try:
            values = frozenset(values)
        except TypeError:
            # list of lists isn't hashable, for example
            raise ValidationError(
                self.error_messages["invalid_list"],
                code="invalid_list",
            )
        pk_values = set()
        natural_key_values = set()
        for item in values:
            query = Q()
            if data_utils.is_uuid(item):
                pk_values.add(item)
                query |= Q(pk=item)
            else:
                natural_key_values.add(item)
                query |= Q(**{self.natural_key: item})
            qs = self.queryset.filter(query)
            if not qs.exists():
                raise ValidationError(
                    self.error_messages["invalid_choice"],
                    code="invalid_choice",
                    params={"value": item},
                )
        query = Q(pk__in=pk_values) | Q(**{f"{self.natural_key}__in": natural_key_values})
        qs = self.queryset.filter(query)
        result = list(qs)
        if null:
            result += [self.null_value]
        return result

nautobot.apps.forms.MultiValueCharField

Bases: django_forms.CharField

CharField that takes multiple user character inputs and render them as tags in the form field. Press enter to complete an input.

Source code in nautobot/core/forms/fields.py
class MultiValueCharField(django_forms.CharField):
    """
    CharField that takes multiple user character inputs and render them as tags in the form field.
    Press enter to complete an input.
    """

    widget = widgets.MultiValueCharInput()

    def get_bound_field(self, form, field_name):
        bound_field = BoundField(form, self, field_name)
        value = bound_field.value()
        widget = bound_field.field.widget
        # Save the selected choices in the widget even after the filterform is submitted
        if value is not None:
            widget.choices = [(v, v) for v in value]

        return bound_field

    def to_python(self, value):
        self.field_class = django_forms.CharField
        if not value:
            return []

        # Make it a list if it's a string.
        if isinstance(value, str):
            value = [value]

        return [
            # Only append non-empty values (this avoids e.g. trying to cast '' as an integer)
            self.field_class.to_python(self, v)
            for v in value
            if v
        ]

nautobot.apps.forms.MultiValueCharInput

Bases: StaticSelect2Multiple

Manual text input with tagging enabled. Press enter to create a new entry.

Source code in nautobot/core/forms/widgets.py
class MultiValueCharInput(StaticSelect2Multiple):
    """
    Manual text input with tagging enabled.
    Press enter to create a new entry.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.attrs["class"] = "nautobot-select2-multi-value-char"

nautobot.apps.forms.MultipleContentTypeField

Bases: django_forms.ModelMultipleChoiceField

Field for choosing any number of ContentType objects.

Optionally can restrict the available ContentTypes to those supporting a particular feature only. Optionally can pass the selection through as a list of {app_label}.{model} strings instead of PK values.

Source code in nautobot/core/forms/fields.py
class MultipleContentTypeField(django_forms.ModelMultipleChoiceField):
    """
    Field for choosing any number of `ContentType` objects.

    Optionally can restrict the available ContentTypes to those supporting a particular feature only.
    Optionally can pass the selection through as a list of `{app_label}.{model}` strings instead of PK values.
    """

    STATIC_CHOICES = True

    def __init__(self, *args, feature=None, choices_as_strings=False, **kwargs):
        """
        Construct a MultipleContentTypeField.

        Args:
            feature (str): Feature name to use in constructing a FeatureQuery to restrict the available ContentTypes.
            choices_as_strings (bool): If True, render selection as a list of `"{app_label}.{model}"` strings.
        """
        from nautobot.extras import utils as extras_utils

        if "queryset" not in kwargs:
            if feature is not None:
                kwargs["queryset"] = ContentType.objects.filter(
                    extras_utils.FeatureQuery(feature).get_query()
                ).order_by("app_label", "model")
            else:
                kwargs["queryset"] = ContentType.objects.order_by("app_label", "model")
        if "widget" not in kwargs:
            kwargs["widget"] = forms.StaticSelect2Multiple()

        super().__init__(*args, **kwargs)

        if choices_as_strings:
            self.choices = self._string_choices_from_queryset

    def _string_choices_from_queryset(self):
        """Overload choices to return `{app_label}.{model}` instead of PKs."""
        return [(f"{m.app_label}.{m.model}", m.app_labeled_name) for m in self.queryset.all()]

__init__(*args, feature=None, choices_as_strings=False, **kwargs)

Construct a MultipleContentTypeField.

Parameters:

Name Type Description Default
feature str

Feature name to use in constructing a FeatureQuery to restrict the available ContentTypes.

None
choices_as_strings bool

If True, render selection as a list of "{app_label}.{model}" strings.

False
Source code in nautobot/core/forms/fields.py
def __init__(self, *args, feature=None, choices_as_strings=False, **kwargs):
    """
    Construct a MultipleContentTypeField.

    Args:
        feature (str): Feature name to use in constructing a FeatureQuery to restrict the available ContentTypes.
        choices_as_strings (bool): If True, render selection as a list of `"{app_label}.{model}"` strings.
    """
    from nautobot.extras import utils as extras_utils

    if "queryset" not in kwargs:
        if feature is not None:
            kwargs["queryset"] = ContentType.objects.filter(
                extras_utils.FeatureQuery(feature).get_query()
            ).order_by("app_label", "model")
        else:
            kwargs["queryset"] = ContentType.objects.order_by("app_label", "model")
    if "widget" not in kwargs:
        kwargs["widget"] = forms.StaticSelect2Multiple()

    super().__init__(*args, **kwargs)

    if choices_as_strings:
        self.choices = self._string_choices_from_queryset

nautobot.apps.forms.NautobotBulkEditForm

Bases: BootstrapMixin, CustomFieldModelBulkEditFormMixin, RelationshipModelBulkEditFormMixin, NoteModelBulkEditFormMixin

Base class for bulk-edit forms for models that support relationships, custom fields and notes.

Source code in nautobot/extras/forms/base.py
class NautobotBulkEditForm(
    BootstrapMixin, CustomFieldModelBulkEditFormMixin, RelationshipModelBulkEditFormMixin, NoteModelBulkEditFormMixin
):
    """Base class for bulk-edit forms for models that support relationships, custom fields and notes."""

nautobot.apps.forms.NautobotFilterForm

Bases: BootstrapMixin, CustomFieldModelFilterFormMixin, RelationshipModelFilterFormMixin

This class exists to combine common functionality and is used to inherit from throughout the codebase where all three of BootstrapMixin, CustomFieldModelFilterFormMixin and RelationshipModelFilterFormMixin are needed.

Source code in nautobot/extras/forms/base.py
class NautobotFilterForm(BootstrapMixin, CustomFieldModelFilterFormMixin, RelationshipModelFilterFormMixin):
    """
    This class exists to combine common functionality and is used to inherit from throughout the
    codebase where all three of BootstrapMixin, CustomFieldModelFilterFormMixin and RelationshipModelFilterFormMixin are
    needed.
    """

nautobot.apps.forms.NautobotModelForm

Bases: BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, NoteModelFormMixin

This class exists to combine common functionality and is used to inherit from throughout the codebase where all of BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, and NoteModelFormMixin are needed.

Source code in nautobot/extras/forms/base.py
class NautobotModelForm(BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, NoteModelFormMixin):
    """
    This class exists to combine common functionality and is used to inherit from throughout the
    codebase where all of BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, and
    NoteModelFormMixin are needed.
    """

nautobot.apps.forms.NoteFormBase

Bases: forms.Form

Base for the NoteModelFormMixin and NoteModelBulkEditFormMixin.

Source code in nautobot/extras/forms/mixins.py
class NoteFormBase(forms.Form):
    """Base for the NoteModelFormMixin and NoteModelBulkEditFormMixin."""

    object_note = CommentField(label="Note")

    def save_note(self, *, instance, user):
        value = self.cleaned_data.get("object_note", "").strip()
        if value:
            note = Note.objects.create(
                note=value,
                assigned_object_type=self.obj_type,
                assigned_object_id=instance.pk,
                user=user,
            )
            logger.debug("Created %s", note)

nautobot.apps.forms.NoteModelBulkEditFormMixin

Bases: BulkEditForm, NoteFormBase

Bulk-edit form mixin for models that support Notes.

Source code in nautobot/extras/forms/mixins.py
class NoteModelBulkEditFormMixin(BulkEditForm, NoteFormBase):
    """Bulk-edit form mixin for models that support Notes."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.obj_type = ContentType.objects.get_for_model(self.model)

nautobot.apps.forms.NumericArrayField

Bases: SimpleArrayField

Basic array field that takes comma-separated or hyphenated ranges.

Source code in nautobot/core/forms/fields.py
class NumericArrayField(SimpleArrayField):
    """Basic array field that takes comma-separated or hyphenated ranges."""

    def to_python(self, value):
        try:
            value = ",".join([str(n) for n in forms.parse_numeric_range(value)])
        except ValueError as error:
            raise ValidationError(error)
        return super().to_python(value)

nautobot.apps.forms.PrefixFieldMixin

Bases: forms.ModelForm

ModelForm mixin for IPNetwork based models.

Source code in nautobot/core/forms/forms.py
class PrefixFieldMixin(forms.ModelForm):
    """
    ModelForm mixin for IPNetwork based models.
    """

    prefix = formfields.IPNetworkFormField()

    def __init__(self, *args, **kwargs):
        instance = kwargs.get("instance")
        initial = kwargs.get("initial", {}).copy()

        # If initial already has a `prefix`, we want to use that `prefix` as it was passed into
        # the form. If we're editing an object with a `prefix` field, we need to patch initial
        # to include `prefix` because it is a computed field.
        if "prefix" not in initial and instance is not None:
            initial["prefix"] = instance.prefix

        kwargs["initial"] = initial

        super().__init__(*args, **kwargs)

    def clean(self):
        super().clean()

        # Need to set instance attribute for `prefix` to run proper validation on Model.clean()
        self.instance.prefix = self.cleaned_data.get("prefix")

nautobot.apps.forms.RelationshipModelBulkEditFormMixin

Bases: BulkEditForm

Bulk-edit form mixin for models that support Relationships.

Source code in nautobot/extras/forms/mixins.py
class RelationshipModelBulkEditFormMixin(BulkEditForm):
    """Bulk-edit form mixin for models that support Relationships."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.obj_type = ContentType.objects.get_for_model(self.model)
        self.relationships = []

        self._append_relationships()

    def _append_relationships(self):
        """
        Append form fields for all Relationships assigned to this model.
        """
        source_relationships = Relationship.objects.filter(source_type=self.obj_type, source_hidden=False)
        self._append_relationships_side(source_relationships, RelationshipSideChoices.SIDE_SOURCE)

        dest_relationships = Relationship.objects.filter(destination_type=self.obj_type, destination_hidden=False)
        self._append_relationships_side(dest_relationships, RelationshipSideChoices.SIDE_DESTINATION)

    def _append_relationships_side(self, relationships, initial_side):
        """
        Helper method to _append_relationships, for processing one "side" of the relationships for this model.

        For different relationship types there are different expectations of the UI:

        - For one-to-one (symmetric or non-symmetric) it doesn't make sense to bulk-set this relationship,
          but we want it to be clearable/nullable.
        - For one-to-many (from the source, "one", side) we likewise want it clearable/nullable but not settable.
        - For one-to-many (from the destination, "many", side) a single value can be set, or it can be nulled.
        - For many-to-many (symmetric or non-symmetric) we provide "add" and "remove" multi-select fields,
          similar to the TagsBulkEditFormMixin behavior. No nullability is provided here.
        """
        for relationship in relationships:
            if relationship.symmetric:
                side = RelationshipSideChoices.SIDE_PEER
            else:
                side = initial_side
            peer_side = RelationshipSideChoices.OPPOSITE[side]

            # If this model is on the "source" side of the relationship, then the field will be named
            # "cr_<relationship_key>__destination" since it's used to pick the destination object(s).
            # If we're on the "destination" side, the field will be "cr_<relationship_key>__source".
            # For a symmetric relationship, both sides are "peer", so the field will be "cr_<relationship_key>__peer"
            field_name = f"cr_{relationship.key}__{peer_side}"

            if field_name in self.relationships:
                # This is a symmetric relationship that we already processed from the opposing "initial_side".
                # No need to process it a second time!
                continue

            if relationship.has_many(side):
                if relationship.type == RelationshipTypeChoices.TYPE_ONE_TO_MANY:
                    # Destination side of a one-to-many field - provide a standard form field for selecting the "one",
                    # as well as making this field nullable.
                    self.fields[field_name] = relationship.to_form_field(side=side)
                    self.nullable_fields.append(field_name)
                else:
                    # Many-to-many field - provide "add" and "remove" form fields like with tags, no nullable option.
                    self.fields[f"add_{field_name}"] = relationship.to_form_field(side=side)
                    self.fields[f"add_{field_name}"].label = "Add " + self.fields[f"add_{field_name}"].label
                    self.fields[f"remove_{field_name}"] = relationship.to_form_field(side=side)
                    self.fields[f"remove_{field_name}"].label = "Remove " + self.fields[f"remove_{field_name}"].label
            else:
                # The "one" side of a one-to-one or one-to-many relationship.
                # In this case, the only valid bulk-edit operation is nulling/clearing the relationship,
                # but the "Set null" checkbox only appears if we have a form field for the the relationship itself.
                # This could probably be refined, but for now we just add the field and disable it.
                self.fields[field_name] = relationship.to_form_field(side=side)
                self.fields[field_name].disabled = True
                self.nullable_fields.append(field_name)

            self.relationships.append(field_name)

    def save_relationships(self, *, instance, nullified_fields):
        """Helper method to be called from BulkEditView.post()."""
        # The below may seem inefficient as it re-loads the Relationship objects afresh for each instance;
        # however this is necessary as it applies the source/destination filters (if any) to determine
        # whether each relationship actually applies to the given instance.
        instance_relationships = instance.get_relationships(include_hidden=True)

        for side, relationships_data in instance_relationships.items():
            peer_side = RelationshipSideChoices.OPPOSITE[side]
            for relationship, relationshipassociation_queryset in relationships_data.items():
                field_name = f"cr_{relationship.key}__{peer_side}"
                logger.debug(
                    "Processing relationship %s %s (field %s) for instance %s",
                    relationship,
                    side,
                    field_name,
                    instance,
                )
                if field_name in self.nullable_fields and field_name in nullified_fields:
                    logger.debug("Deleting existing relationship associations for %s on %s", relationship, instance)
                    relationshipassociation_queryset.delete()
                elif field_name in self.cleaned_data:
                    value = self.cleaned_data.get(field_name)
                    if value and not relationship.has_many(peer_side):
                        ra, created = RelationshipAssociation.objects.update_or_create(
                            relationship=relationship,
                            source_type=relationship.source_type,
                            destination_type=relationship.destination_type,
                            defaults={f"{peer_side}_id": value.pk},
                            **{f"{side}_id": instance.pk},
                        )
                        if created:
                            logger.debug("Created %s", ra)
                        else:
                            logger.debug("Updated %s", ra)
                else:
                    if f"add_{field_name}" in self.cleaned_data:
                        added = self.cleaned_data.get(f"add_{field_name}")
                        for target in added:
                            if peer_side != RelationshipSideChoices.SIDE_PEER:
                                ra, created = RelationshipAssociation.objects.get_or_create(
                                    relationship=relationship,
                                    source_type=relationship.source_type,
                                    destination_type=relationship.destination_type,
                                    **{
                                        f"{side}_id": instance.pk,
                                        f"{peer_side}_id": target.pk,
                                    },
                                )
                            else:
                                if (
                                    RelationshipAssociation.objects.filter(
                                        relationship=relationship,
                                        source_id=instance.pk,
                                        destination_id=target.pk,
                                    ).exists()
                                    or RelationshipAssociation.objects.filter(
                                        relationship=relationship,
                                        source_id=target.pk,
                                        destination_id=instance.pk,
                                    ).exists()
                                ):
                                    ra = None
                                    created = False
                                else:
                                    ra = RelationshipAssociation.objects.create(
                                        relationship=relationship,
                                        source_type=relationship.source_type,
                                        source_id=instance.pk,
                                        destination_type=relationship.destination_type,
                                        destination_id=target.pk,
                                    )
                                    created = True

                            if created:
                                ra.validated_save()
                                logger.debug("Created %s", ra)

                    if f"remove_{field_name}" in self.cleaned_data:
                        removed = self.cleaned_data.get(f"remove_{field_name}")

                        source_count = 0
                        destination_count = 0
                        if side in [RelationshipSideChoices.SIDE_SOURCE, RelationshipSideChoices.SIDE_PEER]:
                            source_count, _ = RelationshipAssociation.objects.filter(
                                relationship=relationship,
                                source_id=instance.pk,
                                destination_id__in=[target.pk for target in removed],
                            ).delete()
                        if side in [RelationshipSideChoices.SIDE_DESTINATION, RelationshipSideChoices.SIDE_PEER]:
                            destination_count, _ = RelationshipAssociation.objects.filter(
                                relationship=relationship,
                                source_id__in=[target.pk for target in removed],
                                destination_id=instance.pk,
                            ).delete()
                        logger.debug("Deleted %s RelationshipAssociation(s)", source_count + destination_count)

    def clean(self):
        # Get any initial required relationship objects errors (i.e. non-existent required objects)
        required_objects_errors = self.model.required_related_objects_errors(output_for="ui")
        already_invalidated_keys = []
        for field, errors in required_objects_errors.items():
            self.add_error(None, errors)
            # rindex() find the last occurrence of "__" which is
            # guaranteed to be cr_{key}__source, cr_{key}__destination, or cr_{key}__peer
            # regardless of how {key} is formatted
            relationship_key = field[: field.rindex("__")][3:]
            already_invalidated_keys.append(relationship_key)

        required_relationships = []
        # The following query excludes already invalidated relationships (this happened above
        # by checking for the existence of required objects
        # with the call to self.Meta().model.required_related_objects_errors(output_for="ui"))
        for relationship in Relationship.objects.get_required_for_model(self.model).exclude(
            key__in=already_invalidated_keys
        ):
            required_relationships.append(
                {
                    "key": relationship.key,
                    "required_side": RelationshipSideChoices.OPPOSITE[relationship.required_on],
                    "relationship": relationship,
                }
            )

        # Get difference of add/remove objects for each required relationship:
        required_relationships_to_check = []
        for required_relationship in required_relationships:
            required_field = f"cr_{required_relationship['key']}__{required_relationship['required_side']}"

            add_list = []
            if f"add_{required_field}" in self.cleaned_data:
                add_list = self.cleaned_data[f"add_{required_field}"]

            remove_list = []
            if f"remove_{required_field}" in self.cleaned_data:
                remove_list = self.cleaned_data[f"remove_{required_field}"]

            # Determine difference of add/remove inputs
            to_add = [obj for obj in add_list if obj not in remove_list]

            # If we are adding at least one relationship association (and also not removing it), further validation is
            # not necessary because at least one object is required for every type of required relationship (one-to-one,
            # one-to-many and many-to-many)
            if len(to_add) > 0:
                continue

            to_remove = [obj for obj in remove_list if obj not in add_list]

            # Add to list of required relationships to enforce on each object being bulk-edited
            required_relationships_to_check.append(
                {
                    "field": required_field,
                    "to_add": to_add,
                    "to_remove": to_remove,
                    "relationship": required_relationship["relationship"],
                }
            )

        relationship_data_errors = {}

        for relationship_to_check in required_relationships_to_check:
            relationship = relationship_to_check["relationship"]
            for editing in self.cleaned_data["pk"]:
                required_target_side = RelationshipSideChoices.OPPOSITE[relationship.required_on]
                required_target_type = getattr(relationship, f"{required_target_side}_type")
                required_type_verbose_name = required_target_type.model_class()._meta.verbose_name
                filter_kwargs = {
                    "relationship": relationship,
                    f"{relationship.required_on}_id": editing.pk,
                }
                existing_objects = [
                    getattr(association, f"get_{RelationshipSideChoices.OPPOSITE[relationship.required_on]}")()
                    for association in RelationshipAssociation.objects.filter(**filter_kwargs)
                ]
                requires_message = (
                    f"{editing._meta.verbose_name_plural} require a {required_type_verbose_name} "
                    f'for the required relationship "{str(relationship)}"'
                )
                if len(existing_objects) == 0 and len(relationship_to_check["to_add"]) == 0:
                    relationship_data_errors.setdefault(requires_message, []).append(str(editing))
                else:
                    removed = relationship_to_check["to_remove"]
                    difference = [obj for obj in existing_objects if obj not in removed]
                    if len(difference) == 0:
                        relationship_data_errors.setdefault(requires_message, []).append(str(editing))

        for relationship_message, object_list in relationship_data_errors.items():
            if len(object_list) > 5:
                self.add_error(None, f"{len(object_list)} {relationship_message}")
            else:
                self.add_error(None, f"These {relationship_message}: {', '.join(object_list)}")

        return super().clean()

save_relationships(*, instance, nullified_fields)

Helper method to be called from BulkEditView.post().

Source code in nautobot/extras/forms/mixins.py
def save_relationships(self, *, instance, nullified_fields):
    """Helper method to be called from BulkEditView.post()."""
    # The below may seem inefficient as it re-loads the Relationship objects afresh for each instance;
    # however this is necessary as it applies the source/destination filters (if any) to determine
    # whether each relationship actually applies to the given instance.
    instance_relationships = instance.get_relationships(include_hidden=True)

    for side, relationships_data in instance_relationships.items():
        peer_side = RelationshipSideChoices.OPPOSITE[side]
        for relationship, relationshipassociation_queryset in relationships_data.items():
            field_name = f"cr_{relationship.key}__{peer_side}"
            logger.debug(
                "Processing relationship %s %s (field %s) for instance %s",
                relationship,
                side,
                field_name,
                instance,
            )
            if field_name in self.nullable_fields and field_name in nullified_fields:
                logger.debug("Deleting existing relationship associations for %s on %s", relationship, instance)
                relationshipassociation_queryset.delete()
            elif field_name in self.cleaned_data:
                value = self.cleaned_data.get(field_name)
                if value and not relationship.has_many(peer_side):
                    ra, created = RelationshipAssociation.objects.update_or_create(
                        relationship=relationship,
                        source_type=relationship.source_type,
                        destination_type=relationship.destination_type,
                        defaults={f"{peer_side}_id": value.pk},
                        **{f"{side}_id": instance.pk},
                    )
                    if created:
                        logger.debug("Created %s", ra)
                    else:
                        logger.debug("Updated %s", ra)
            else:
                if f"add_{field_name}" in self.cleaned_data:
                    added = self.cleaned_data.get(f"add_{field_name}")
                    for target in added:
                        if peer_side != RelationshipSideChoices.SIDE_PEER:
                            ra, created = RelationshipAssociation.objects.get_or_create(
                                relationship=relationship,
                                source_type=relationship.source_type,
                                destination_type=relationship.destination_type,
                                **{
                                    f"{side}_id": instance.pk,
                                    f"{peer_side}_id": target.pk,
                                },
                            )
                        else:
                            if (
                                RelationshipAssociation.objects.filter(
                                    relationship=relationship,
                                    source_id=instance.pk,
                                    destination_id=target.pk,
                                ).exists()
                                or RelationshipAssociation.objects.filter(
                                    relationship=relationship,
                                    source_id=target.pk,
                                    destination_id=instance.pk,
                                ).exists()
                            ):
                                ra = None
                                created = False
                            else:
                                ra = RelationshipAssociation.objects.create(
                                    relationship=relationship,
                                    source_type=relationship.source_type,
                                    source_id=instance.pk,
                                    destination_type=relationship.destination_type,
                                    destination_id=target.pk,
                                )
                                created = True

                        if created:
                            ra.validated_save()
                            logger.debug("Created %s", ra)

                if f"remove_{field_name}" in self.cleaned_data:
                    removed = self.cleaned_data.get(f"remove_{field_name}")

                    source_count = 0
                    destination_count = 0
                    if side in [RelationshipSideChoices.SIDE_SOURCE, RelationshipSideChoices.SIDE_PEER]:
                        source_count, _ = RelationshipAssociation.objects.filter(
                            relationship=relationship,
                            source_id=instance.pk,
                            destination_id__in=[target.pk for target in removed],
                        ).delete()
                    if side in [RelationshipSideChoices.SIDE_DESTINATION, RelationshipSideChoices.SIDE_PEER]:
                        destination_count, _ = RelationshipAssociation.objects.filter(
                            relationship=relationship,
                            source_id__in=[target.pk for target in removed],
                            destination_id=instance.pk,
                        ).delete()
                    logger.debug("Deleted %s RelationshipAssociation(s)", source_count + destination_count)

nautobot.apps.forms.RelationshipModelFilterFormMixin

Bases: forms.Form

Source code in nautobot/extras/forms/mixins.py
class RelationshipModelFilterFormMixin(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.relationships = []
        self.obj_type = ContentType.objects.get_for_model(self.model)
        self._append_relationships()

    def _append_relationships(self):
        """
        Append form fields for all Relationships assigned to this model.
        """
        query = Q(source_type=self.obj_type, source_hidden=False) | Q(
            destination_type=self.obj_type, destination_hidden=False
        )
        relationships = Relationship.objects.select_related("source_type", "destination_type").filter(query)

        for rel in relationships.iterator():
            if rel.source_type == self.obj_type and not rel.source_hidden:
                self._append_relationships_side([rel], RelationshipSideChoices.SIDE_SOURCE)
            if rel.destination_type == self.obj_type and not rel.destination_hidden:
                self._append_relationships_side([rel], RelationshipSideChoices.SIDE_DESTINATION)

    def _append_relationships_side(self, relationships, initial_side):
        """
        Helper method to _append_relationships, for processing one "side" of the relationships for this model.
        """
        for relationship in relationships:
            if relationship.symmetric:
                side = RelationshipSideChoices.SIDE_PEER
            else:
                side = initial_side
            peer_side = RelationshipSideChoices.OPPOSITE[side]

            # If this model is on the "source" side of the relationship, then the field will be named
            # "cr_<relationship_key>__destination" since it's used to pick the destination object(s).
            # If we're on the "destination" side, the field will be "cr_<relationship_key>__source".
            # For a symmetric relationship, both sides are "peer", so the field will be "cr_<relationship_key>__peer"
            field_name = f"cr_{relationship.key}__{peer_side}"

            if field_name in self.relationships:
                # This is a symmetric relationship that we already processed from the opposing "initial_side".
                # No need to process it a second time!
                continue
            self.fields[field_name] = relationship.to_form_field(side=side)
            self.fields[field_name].empty_label = None
            self.relationships.append(field_name)

nautobot.apps.forms.RelationshipModelFormMixin

Bases: forms.ModelForm

Source code in nautobot/extras/forms/mixins.py
class RelationshipModelFormMixin(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        self.obj_type = ContentType.objects.get_for_model(self._meta.model)
        self.relationships = []
        super().__init__(*args, **kwargs)

        self._append_relationships()

    def _append_relationships(self):
        """
        Append form fields for all Relationships assigned to this model.
        One form field per side will be added to the list.
        """
        for side, relationships in self.instance.get_relationships().items():
            for relationship, queryset in relationships.items():
                peer_side = RelationshipSideChoices.OPPOSITE[side]
                # If this model is on the "source" side of the relationship, then the field will be named
                # cr_<relationship_key>__destination since it's used to pick the destination object(s).
                # If we're on the "destination" side, the field will be cr_<relationship_key>__source.
                # For a symmetric relationship, both sides are "peer", so the field will be cr_<relationship_key>__peer
                field_name = f"cr_{relationship.key}__{peer_side}"
                self.fields[field_name] = relationship.to_form_field(side=side)

                # HTML5 validation for required relationship field:
                if relationship.required_on == side:
                    self.fields[field_name].required = True

                # if the object already exists, populate the field with existing values
                if self.instance.present_in_database:
                    if relationship.has_many(peer_side):
                        initial = [association.get_peer(self.instance) for association in queryset.all()]
                        self.fields[field_name].initial = initial
                    else:
                        association = queryset.first()
                        if association:
                            self.fields[field_name].initial = association.get_peer(self.instance)

                # Annotate the field in the list of Relationship form fields
                self.relationships.append(field_name)

    def clean(self):
        """
        First check for any required relationships errors and if there are any, add them via form field errors.
        Then verify that any requested RelationshipAssociations do not violate relationship cardinality restrictions.

        - For TYPE_ONE_TO_MANY and TYPE_ONE_TO_ONE relations, if the form's object is on the "source" side of
          the relationship, verify that the requested "destination" object(s) do not already have any existing
          RelationshipAssociation to a different source object.
        - For TYPE_ONE_TO_ONE relations, if the form's object is on the "destination" side of the relationship,
          verify that the requested "source" object does not have an existing RelationshipAssociation to
          a different destination object.
        """
        required_relationships_errors = self.Meta().model.required_related_objects_errors(
            output_for="ui", initial_data=self.cleaned_data, instance=self.instance
        )
        for field, errors in required_relationships_errors.items():
            self.add_error(field, errors)

        for side, relationships in self.instance.get_relationships().items():
            for relationship in relationships:
                # The form field name reflects what it provides, i.e. the peer object(s) to link via this relationship.
                peer_side = RelationshipSideChoices.OPPOSITE[side]
                field_name = f"cr_{relationship.key}__{peer_side}"

                # Is the form trying to set this field (create/update a RelationshipAssociation(s))?
                # If not (that is, clearing the field / deleting RelationshipAssociation(s)), we don't need to check.
                if field_name not in self.cleaned_data or not self.cleaned_data[field_name]:
                    continue

                # Are any of the objects we want a relationship with already entangled with another object?
                if relationship.has_many(peer_side):
                    target_peers = list(self.cleaned_data[field_name])
                else:
                    target_peers = [self.cleaned_data[field_name]]

                for target_peer in target_peers:
                    if target_peer.pk == self.instance.pk:
                        raise ValidationError(
                            {field_name: f"Object {self.instance} cannot form a relationship to itself!"}
                        )

                    if relationship.has_many(side):
                        # No need to check for existing RelationshipAssociations since this is a "many" relationship
                        continue

                    if not relationship.symmetric:
                        existing_peer_associations = RelationshipAssociation.objects.filter(
                            relationship=relationship,
                            **{
                                f"{peer_side}_id": target_peer.pk,
                            },
                        ).exclude(**{f"{side}_id": self.instance.pk})
                    else:
                        existing_peer_associations = RelationshipAssociation.objects.filter(
                            (
                                (Q(source_id=target_peer.pk) & ~Q(destination_id=self.instance.pk))
                                | (Q(destination_id=target_peer.pk) & ~Q(source_id=self.instance.pk))
                            ),
                            relationship=relationship,
                        )

                    if existing_peer_associations.exists():
                        raise ValidationError(
                            {field_name: f"{target_peer} is already involved in a {relationship} relationship"}
                        )

        super().clean()

    def _save_relationships(self):
        """Update RelationshipAssociations for all Relationships on form save."""

        for field_name in self.relationships:
            # The field name tells us the side of the relationship that it is providing peer objects(s) to link into.
            peer_side = field_name.split("__")[-1]
            # Based on the side of the relationship that our local object represents,
            # find the list of existing RelationshipAssociations it already has for this Relationship.
            side = RelationshipSideChoices.OPPOSITE[peer_side]
            filters = {
                "relationship": self.fields[field_name].model,
            }
            if side != RelationshipSideChoices.SIDE_PEER:
                filters.update({f"{side}_type": self.obj_type, f"{side}_id": self.instance.pk})
                existing_associations = RelationshipAssociation.objects.filter(**filters)
            else:
                existing_associations = RelationshipAssociation.objects.filter(
                    (
                        Q(source_type=self.obj_type, source_id=self.instance.pk)
                        | Q(destination_type=self.obj_type, destination_id=self.instance.pk)
                    ),
                    **filters,
                )

            # Get the list of target peer ids (PKs) that are specified in the form
            target_peer_ids = []
            if hasattr(self.cleaned_data[field_name], "__iter__"):
                # One-to-many or many-to-many association
                target_peer_ids = [item.pk for item in self.cleaned_data[field_name]]
            elif self.cleaned_data[field_name]:
                # Many-to-one or one-to-one association
                target_peer_ids = [self.cleaned_data[field_name].pk]
            else:
                # Unset/delete case
                target_peer_ids = []

            # Create/delete RelationshipAssociations as needed to match the target_peer_ids list

            # First, for each existing association, if it's one that's already in target_peer_ids,
            # we can discard it from target_peer_ids (no update needed to this association).
            # Conversely, if it's *not* in target_peer_ids, we should delete it.
            for association in existing_associations:
                for peer_id in target_peer_ids:
                    if peer_side != RelationshipSideChoices.SIDE_PEER:
                        if peer_id == getattr(association, f"{peer_side}_id"):
                            # This association already exists, so we can ignore it
                            target_peer_ids.remove(peer_id)
                            break
                    else:
                        if peer_id == association.source_id or peer_id == association.destination_id:
                            # This association already exists, so we can ignore it
                            target_peer_ids.remove(peer_id)
                            break
                else:
                    # This association is not in target_peer_ids, so delete it
                    association.delete()

            # Anything remaining in target_peer_ids now does not exist yet and needs to be created.
            for peer_id in target_peer_ids:
                relationship = self.fields[field_name].model
                if not relationship.symmetric:
                    association = RelationshipAssociation(
                        relationship=relationship,
                        **{
                            f"{side}_type": self.obj_type,
                            f"{side}_id": self.instance.pk,
                            f"{peer_side}_type": getattr(relationship, f"{peer_side}_type"),
                            f"{peer_side}_id": peer_id,
                        },
                    )
                else:
                    # Symmetric association - source/destination are interchangeable
                    association = RelationshipAssociation(
                        relationship=relationship,
                        source_type=self.obj_type,
                        source_id=self.instance.pk,
                        destination_type=self.obj_type,  # since this is a symmetric relationship this is OK
                        destination_id=peer_id,
                    )

                association.clean()
                association.save()

    def save(self, commit=True):
        obj = super().save(commit)
        if commit:
            self._save_relationships()

        return obj

clean()

First check for any required relationships errors and if there are any, add them via form field errors. Then verify that any requested RelationshipAssociations do not violate relationship cardinality restrictions.

  • For TYPE_ONE_TO_MANY and TYPE_ONE_TO_ONE relations, if the form's object is on the "source" side of the relationship, verify that the requested "destination" object(s) do not already have any existing RelationshipAssociation to a different source object.
  • For TYPE_ONE_TO_ONE relations, if the form's object is on the "destination" side of the relationship, verify that the requested "source" object does not have an existing RelationshipAssociation to a different destination object.
Source code in nautobot/extras/forms/mixins.py
def clean(self):
    """
    First check for any required relationships errors and if there are any, add them via form field errors.
    Then verify that any requested RelationshipAssociations do not violate relationship cardinality restrictions.

    - For TYPE_ONE_TO_MANY and TYPE_ONE_TO_ONE relations, if the form's object is on the "source" side of
      the relationship, verify that the requested "destination" object(s) do not already have any existing
      RelationshipAssociation to a different source object.
    - For TYPE_ONE_TO_ONE relations, if the form's object is on the "destination" side of the relationship,
      verify that the requested "source" object does not have an existing RelationshipAssociation to
      a different destination object.
    """
    required_relationships_errors = self.Meta().model.required_related_objects_errors(
        output_for="ui", initial_data=self.cleaned_data, instance=self.instance
    )
    for field, errors in required_relationships_errors.items():
        self.add_error(field, errors)

    for side, relationships in self.instance.get_relationships().items():
        for relationship in relationships:
            # The form field name reflects what it provides, i.e. the peer object(s) to link via this relationship.
            peer_side = RelationshipSideChoices.OPPOSITE[side]
            field_name = f"cr_{relationship.key}__{peer_side}"

            # Is the form trying to set this field (create/update a RelationshipAssociation(s))?
            # If not (that is, clearing the field / deleting RelationshipAssociation(s)), we don't need to check.
            if field_name not in self.cleaned_data or not self.cleaned_data[field_name]:
                continue

            # Are any of the objects we want a relationship with already entangled with another object?
            if relationship.has_many(peer_side):
                target_peers = list(self.cleaned_data[field_name])
            else:
                target_peers = [self.cleaned_data[field_name]]

            for target_peer in target_peers:
                if target_peer.pk == self.instance.pk:
                    raise ValidationError(
                        {field_name: f"Object {self.instance} cannot form a relationship to itself!"}
                    )

                if relationship.has_many(side):
                    # No need to check for existing RelationshipAssociations since this is a "many" relationship
                    continue

                if not relationship.symmetric:
                    existing_peer_associations = RelationshipAssociation.objects.filter(
                        relationship=relationship,
                        **{
                            f"{peer_side}_id": target_peer.pk,
                        },
                    ).exclude(**{f"{side}_id": self.instance.pk})
                else:
                    existing_peer_associations = RelationshipAssociation.objects.filter(
                        (
                            (Q(source_id=target_peer.pk) & ~Q(destination_id=self.instance.pk))
                            | (Q(destination_id=target_peer.pk) & ~Q(source_id=self.instance.pk))
                        ),
                        relationship=relationship,
                    )

                if existing_peer_associations.exists():
                    raise ValidationError(
                        {field_name: f"{target_peer} is already involved in a {relationship} relationship"}
                    )

    super().clean()

nautobot.apps.forms.ReturnURLForm

Bases: forms.Form

Provides a hidden return URL field to control where the user is directed after the form is submitted.

Source code in nautobot/core/forms/forms.py
class ReturnURLForm(forms.Form):
    """
    Provides a hidden return URL field to control where the user is directed after the form is submitted.
    """

    return_url = forms.CharField(required=False, widget=forms.HiddenInput())

nautobot.apps.forms.RoleModelBulkEditFormMixin

Bases: forms.Form

Mixin to add non-required role choice field to forms.

Source code in nautobot/extras/forms/mixins.py
class RoleModelBulkEditFormMixin(forms.Form):
    """Mixin to add non-required `role` choice field to forms."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["role"] = DynamicModelChoiceField(
            required=False,
            queryset=Role.objects.all(),
            query_params={"content_types": self.model._meta.label_lower},
        )
        self.order_fields(self.field_order)  # Reorder fields again

nautobot.apps.forms.RoleModelFilterFormMixin

Bases: forms.Form

Mixin to add non-required role multiple-choice field to filter forms.

Source code in nautobot/extras/forms/mixins.py
class RoleModelFilterFormMixin(forms.Form):
    """
    Mixin to add non-required `role` multiple-choice field to filter forms.
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.fields["role"] = DynamicModelMultipleChoiceField(
            required=False,
            queryset=Role.objects.all(),
            query_params={"content_types": self.model._meta.label_lower},
            to_field_name="name",
        )
        self.order_fields(self.field_order)  # Reorder fields again

nautobot.apps.forms.SelectWithDisabled

Bases: forms.Select

Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include 'label' (string) and 'disabled' (boolean).

Source code in nautobot/core/forms/widgets.py
class SelectWithDisabled(forms.Select):
    """
    Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
    'label' (string) and 'disabled' (boolean).
    """

    option_template_name = "widgets/selectwithdisabled_option.html"

nautobot.apps.forms.SelectWithPK

Bases: StaticSelect2

Include the primary key of each option in the option label (e.g. "Router7 (4721)").

Source code in nautobot/core/forms/widgets.py
class SelectWithPK(StaticSelect2):
    """
    Include the primary key of each option in the option label (e.g. "Router7 (4721)").
    """

    option_template_name = "widgets/select_option_with_pk.html"

nautobot.apps.forms.SlugField

Bases: django_forms.SlugField

Extend the built-in SlugField to automatically populate from a field called name unless otherwise specified.

Source code in nautobot/core/forms/fields.py
class SlugField(django_forms.SlugField):
    """
    Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
    """

    def __init__(self, slug_source="name", *args, **kwargs):
        """
        Instantiate a SlugField.

        Args:
            slug_source (str, tuple): Name of the field (or a list of field names) that will be used to suggest a slug.
        """
        kwargs.setdefault("label", "Slug")
        kwargs.setdefault("help_text", "URL-friendly unique shorthand")
        kwargs.setdefault("widget", forms.SlugWidget)
        super().__init__(*args, **kwargs)
        if isinstance(slug_source, (tuple, list)):
            slug_source = " ".join(slug_source)
        self.widget.attrs["slug-source"] = slug_source

__init__(slug_source='name', *args, **kwargs)

Instantiate a SlugField.

Parameters:

Name Type Description Default
slug_source (str, tuple)

Name of the field (or a list of field names) that will be used to suggest a slug.

'name'
Source code in nautobot/core/forms/fields.py
def __init__(self, slug_source="name", *args, **kwargs):
    """
    Instantiate a SlugField.

    Args:
        slug_source (str, tuple): Name of the field (or a list of field names) that will be used to suggest a slug.
    """
    kwargs.setdefault("label", "Slug")
    kwargs.setdefault("help_text", "URL-friendly unique shorthand")
    kwargs.setdefault("widget", forms.SlugWidget)
    super().__init__(*args, **kwargs)
    if isinstance(slug_source, (tuple, list)):
        slug_source = " ".join(slug_source)
    self.widget.attrs["slug-source"] = slug_source

nautobot.apps.forms.SlugWidget

Bases: forms.TextInput

Subclass TextInput and add a slug regeneration button next to the form field.

Source code in nautobot/core/forms/widgets.py
class SlugWidget(forms.TextInput):
    """
    Subclass TextInput and add a slug regeneration button next to the form field.
    """

    template_name = "widgets/sluginput.html"

nautobot.apps.forms.SmallTextarea

Bases: forms.Textarea

Subclass used for rendering a smaller textarea element.

Source code in nautobot/core/forms/widgets.py
class SmallTextarea(forms.Textarea):
    """
    Subclass used for rendering a smaller textarea element.
    """

nautobot.apps.forms.StaticSelect2

Bases: SelectWithDisabled

A static