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`

    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    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`

    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    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`

    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    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`

    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    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`

    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    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`

    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    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`

    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    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`

    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    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`

    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    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`

    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    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`

    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    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`

     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    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`

    105
    106
    107
    108
    109
    110
    111
    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`

    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    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`

    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    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`

    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    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`

    136
    137
    138
    139
    140
    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`

    142
    143
    144
    145
    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`

    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    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`

    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    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`

    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    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`

    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    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 = ""

        @property
        def default_helptext(self):
            # TODO: Port Markdown cheat sheet to internal documentation
            return format_html(
                '<i class="mdi mdi-information-outline"></i> '
                '<a href="https://www.markdownguide.org/cheat-sheet/#basic-syntax" rel="noopener noreferrer">Markdown</a> '
                'syntax is supported, as well as <a href="{}#render_markdown">a limited subset of HTML</a>.',
                static("docs/user-guide/platform-functionality/template-filters.html"),
            )

        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`

    92
    93
    94
    95
    96
    97
    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: console server port This attribute can be used to reference the relevant API endpoint for a particular ContentType.

Source code in `nautobot/core/forms/widgets.py`

    109
    110
    111
    112
    113
    114
    115
    116
    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`

    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    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`

     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    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`

    201
    202
    203
    204
    205
    206
    207
    208
    209
    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`

    212
    213
    214
    215
    216
    217
    218
    219
    220
    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`

    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    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`

    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    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`

    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    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`

    565
    566
    567
    568
    569
    570
    571
    572
    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`

    443
    444
    445
    446
    447
    448
    449
    450
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    556
    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`

    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    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`

    575
    576
    577
    578
    579
    580
    581
    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`

    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    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`

    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    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`

    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    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`

    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    638
    639
    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    685
    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    701
    702
    703
    704
    705
    706
    707
    708
    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`

    630
    631
    632
    633
    634
    635
    636
    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`

    701
    702
    703
    704
    705
    706
    707
    708
    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`

    638
    639
    640
    641
    642
    643
    644
    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`

    686
    687
    688
    689
    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    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`

    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    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`

    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    683
    684
    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`

    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    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`

    584
    585
    586
    587
    588
    589
    590
    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`

    722
    723
    724
    725
    726
    727
    728
    729
    730
    731
    732
    733
    734
    735
    736
    737
    738
    739
    740
    741
    742
    743
    744
    745
    746
    747
    748
    749
    750
    751
    752
    753
    754
    755
    756
    757
    758
    759
    760
    761
    762
    763
    764
    765
    766
    767
    768
    769
    770
    771
    772
    773
    774
    775
    776
    777
    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`

    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    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`

    234
    235
    236
    237
    238
    239
    240
    241
    242
    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`

    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    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`

    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    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`

    42
    43
    44
    45
    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`

    34
    35
    36
    37
    38
    39
    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`

    26
    27
    28
    29
    30
    31
    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`

    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    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`

    156
    157
    158
    159
    160
    161
    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`

    711
    712
    713
    714
    715
    716
    717
    718
    719
    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`

    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    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`

    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    245
    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    342
    343
    344
    345
    346
    347
    348
    349
    350
    351
    352
    353
    354
    355
    356
    357
    358
    359
    360
    361
    362
    363
    364
    365
    366
    367
    368
    369
    370
    371
    372
    373
    374
    375
    376
    377
    378
    379
    380
    381
    382
    383
    384
    385
    386
    387
    388
    389
    390
    391
    392
    393
    394
    395
    396
    397
    398
    399
    400
    401
    402
    403
    404
    405
    406
    407
    408
    409
    410
    411
    412
    413
    414
    415
    416
    417
    418
    419
    420
    421
    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    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 "{relationship!s}"'
                    )
                    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`

    246
    247
    248
    249
    250
    251
    252
    253
    254
    255
    256
    257
    258
    259
    260
    261
    262
    263
    264
    265
    266
    267
    268
    269
    270
    271
    272
    273
    274
    275
    276
    277
    278
    279
    280
    281
    282
    283
    284
    285
    286
    287
    288
    289
    290
    291
    292
    293
    294
    295
    296
    297
    298
    299
    300
    301
    302
    303
    304
    305
    306
    307
    308
    309
    310
    311
    312
    313
    314
    315
    316
    317
    318
    319
    320
    321
    322
    323
    324
    325
    326
    327
    328
    329
    330
    331
    332
    333
    334
    335
    336
    337
    338
    339
    340
    341
    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`

    640
    641
    642
    643
    644
    645
    646
    647
    648
    649
    650
    651
    652
    653
    654
    655
    656
    657
    658
    659
    660
    661
    662
    663
    664
    665
    666
    667
    668
    669
    670
    671
    672
    673
    674
    675
    676
    677
    678
    679
    680
    681
    682
    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.
            """
            src_relationships, dst_relationships = Relationship.objects.get_for_model(model=self.model, hidden=False)

            for rel in src_relationships:
                self._append_relationships_side([rel], RelationshipSideChoices.SIDE_SOURCE)

            for rel in dst_relationships:
                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`

    441
    442
    443
    444
    445
    446
    447
    448
    449
    450
    451
    452
    453
    454
    455
    456
    457
    458
    459
    460
    461
    462
    463
    464
    465
    466
    467
    468
    469
    470
    471
    472
    473
    474
    475
    476
    477
    478
    479
    480
    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    548
    549
    550
    551
    552
    553
    554
    555
    556
    557
    558
    559
    560
    561
    562
    563
    564
    565
    566
    567
    568
    569
    570
    571
    572
    573
    574
    575
    576
    577
    578
    579
    580
    581
    582
    583
    584
    585
    586
    587
    588
    589
    590
    591
    592
    593
    594
    595
    596
    597
    598
    599
    600
    601
    602
    603
    604
    605
    606
    607
    608
    609
    610
    611
    612
    613
    614
    615
    616
    617
    618
    619
    620
    621
    622
    623
    624
    625
    626
    627
    628
    629
    630
    631
    632
    633
    634
    635
    636
    637
    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`

    481
    482
    483
    484
    485
    486
    487
    488
    489
    490
    491
    492
    493
    494
    495
    496
    497
    498
    499
    500
    501
    502
    503
    504
    505
    506
    507
    508
    509
    510
    511
    512
    513
    514
    515
    516
    517
    518
    519
    520
    521
    522
    523
    524
    525
    526
    527
    528
    529
    530
    531
    532
    533
    534
    535
    536
    537
    538
    539
    540
    541
    542
    543
    544
    545
    546
    547
    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`

    84
    85
    86
    87
    88
    89
    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`

    690
    691
    692
    693
    694
    695
    696
    697
    698
    699
    700
    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`

    703
    704
    705
    706
    707
    708
    709
    710
    711
    712
    713
    714
    715
    716
    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`

    74
    75
    76
    77
    78
    79
    80
    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`

    101
    102
    103
    104
    105
    106
    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`

    422
    423
    424
    425
    426
    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    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`

    427
    428
    429
    430
    431
    432
    433
    434
    435
    436
    437
    438
    439
    440
    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`

    36
    37
    38
    39
    40
    41
    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`

    30
    31
    32
    33
    class SmallTextarea(forms.Textarea):
        """
        Subclass used for rendering a smaller textarea element.
        """

## `nautobot.apps.forms.StaticSelect2`

Bases: `SelectWithDisabled`

A static

Source code in `nautobot/core/forms/widgets.py`

    83
    84
    85
    86
    87
    88
    89
    90
    91
    class StaticSelect2(SelectWithDisabled):
        """
        A static <select> form widget using the Select2 library.
        """

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

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

## `nautobot.apps.forms.StatusModelBulkEditFormMixin`

Bases: `forms.Form`

Mixin to add non-required `status` choice field to forms.

Source code in `nautobot/extras/forms/mixins.py`

    719
    720
    721
    722
    723
    724
    725
    726
    727
    728
    729
    class StatusModelBulkEditFormMixin(forms.Form):
        """Mixin to add non-required `status` choice field to forms."""

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

## `nautobot.apps.forms.StatusModelFilterFormMixin`

Bases: `forms.Form`

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

Source code in `nautobot/extras/forms/mixins.py`

    732
    733
    734
    735
    736
    737
    738
    739
    740
    741
    742
    743
    744
    745
    class StatusModelFilterFormMixin(forms.Form):
        """
        Mixin to add non-required `status` multiple-choice field to filter forms.
        """

        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.fields["status"] = DynamicModelMultipleChoiceField(
                required=False,
                queryset=Status.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.TableConfigForm`

Bases: `BootstrapMixin`, `forms.Form`

Form for configuring user's table preferences.

Source code in `nautobot/core/forms/forms.py`

    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    236
    237
    238
    239
    240
    241
    242
    243
    244
    class TableConfigForm(BootstrapMixin, forms.Form):
        """
        Form for configuring user's table preferences.
        """

        columns = forms.MultipleChoiceField(
            choices=[],
            required=False,
            widget=forms.SelectMultiple(attrs={"size": 10}),
            help_text="Use the buttons below to arrange columns in the desired order, then select all columns to display.",
        )

        def __init__(self, table, *args, **kwargs):
            self.table = table

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

            # Initialize columns field based on table attributes
            self.fields["columns"].choices = table.configurable_columns
            self.fields["columns"].initial = table.visible_columns

        @property
        def table_name(self):
            return self.table.__class__.__name__

## `nautobot.apps.forms.TagFilterField`

Bases: `DynamicModelMultipleChoiceField`

A filter field for the tags of a model. Only the tags used by a model are displayed.

:param model: The model of the filter

Source code in `nautobot/core/forms/fields.py`

    780
    781
    782
    783
    784
    785
    786
    787
    788
    789
    790
    791
    792
    793
    794
    795
    796
    797
    798
    799
    800
    801
    802
    class TagFilterField(DynamicModelMultipleChoiceField):
        """
        A filter field for the tags of a model. Only the tags used by a model are displayed.

        :param model: The model of the filter
        """

        def __init__(self, model, *args, query_params=None, queryset=None, **kwargs):
            from nautobot.extras.models import Tag

            if queryset is None:
                queryset = Tag.objects.get_for_model(model)
            query_params = query_params or {}
            query_params.update({"content_types": model._meta.label_lower})
            super().__init__(
                label="Tags",
                query_params=query_params,
                queryset=queryset,
                required=False,
                to_field_name="name",
                *args,
                **kwargs,
            )

### `__init__(model, *args, query_params=None, queryset=None, **kwargs)`

Source code in `nautobot/core/forms/fields.py`

    787
    788
    789
    790
    791
    792
    793
    794
    795
    796
    797
    798
    799
    800
    801
    802
    def __init__(self, model, *args, query_params=None, queryset=None, **kwargs):
        from nautobot.extras.models import Tag

        if queryset is None:
            queryset = Tag.objects.get_for_model(model)
        query_params = query_params or {}
        query_params.update({"content_types": model._meta.label_lower})
        super().__init__(
            label="Tags",
            query_params=query_params,
            queryset=queryset,
            required=False,
            to_field_name="name",
            *args,
            **kwargs,
        )

## `nautobot.apps.forms.TimePicker`

Bases: `forms.TextInput`

Time picker using Flatpickr.

Source code in `nautobot/core/forms/widgets.py`

    223
    224
    225
    226
    227
    228
    229
    230
    231
    class TimePicker(forms.TextInput):
        """
        Time picker using Flatpickr.
        """

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

## `nautobot.apps.forms.add_blank_choice(choices)`

Add a blank choice to the beginning of a choices list.

Source code in `nautobot/core/forms/utils.py`

    112
    113
    114
    115
    116
    def add_blank_choice(choices):
        """
        Add a blank choice to the beginning of a choices list.
        """
        return ((None, "---------"), *tuple(choices))

## `nautobot.apps.forms.add_field_to_filter_form_class(form_class, field_name, field_obj)`

Attach a field to an existing filter form class.

Source code in `nautobot/core/forms/utils.py`

    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    def add_field_to_filter_form_class(form_class, field_name, field_obj):
        """
        Attach a field to an existing filter form class.
        """
        if not isinstance(field_obj, django_forms.Field):
            raise TypeError(f"Custom form field `{field_name}` is not an instance of django.forms.Field.")
        if field_name in form_class.base_fields:
            raise AttributeError(
                f"There was a conflict with filter form field `{field_name}`, the custom filter form field was ignored."
            )
        form_class.base_fields[field_name] = field_obj

## `nautobot.apps.forms.expand_alphanumeric_pattern(string)`

Expand an alphabetic pattern into a list of strings.

Source code in `nautobot/core/forms/utils.py`

    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    def expand_alphanumeric_pattern(string):
        """
        Expand an alphabetic pattern into a list of strings.
        """
        lead, pattern, remnant = re.split(forms.ALPHANUMERIC_EXPANSION_PATTERN, string, maxsplit=1)
        parsed_range = parse_alphanumeric_range(pattern)
        for i in parsed_range:
            if re.search(forms.ALPHANUMERIC_EXPANSION_PATTERN, remnant):
                for string2 in expand_alphanumeric_pattern(remnant):
                    yield f"{lead}{i}{string2}"
            else:
                yield f"{lead}{i}{remnant}"

## `nautobot.apps.forms.expand_ipaddress_pattern(string, ip_version)`

Expand an IP address pattern into a list of strings. Examples: '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24'] '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']

Source code in `nautobot/core/forms/utils.py`

     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    def expand_ipaddress_pattern(string, ip_version):
        """
        Expand an IP address pattern into a list of strings. Examples:
          '192.0.2.[1,2,100-250]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.100/24' ... '192.0.2.250/24']
          '2001:db8:0:[0,fd-ff]::/64' => ['2001:db8:0:0::/64', '2001:db8:0:fd::/64', ... '2001:db8:0:ff::/64']
        """
        if ip_version not in [4, 6]:
            raise ValueError(f"Invalid IP address version: {ip_version}")
        if ip_version == 4:
            regex = forms.IP4_EXPANSION_PATTERN
            base = 10
        else:
            regex = forms.IP6_EXPANSION_PATTERN
            base = 16
        lead, pattern, remnant = re.split(regex, string, maxsplit=1)
        parsed_range = parse_numeric_range(pattern, base)
        for i in parsed_range:
            if re.search(regex, remnant):
                for string2 in expand_ipaddress_pattern(remnant, ip_version):
                    yield "".join([lead, format(i, "x" if ip_version == 6 else "d"), string2])
            else:
                yield "".join([lead, format(i, "x" if ip_version == 6 else "d"), remnant])

## `nautobot.apps.forms.form_from_model(model, fields)`

Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields are marked as not required.

Source code in `nautobot/core/forms/utils.py`

    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    def form_from_model(model, fields):
        """
        Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used
        for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields
        are marked as not required.
        """
        form_fields = fields_for_model(model, fields=fields)
        for field in form_fields.values():
            field.required = False

        return type("FormFromModel", (django_forms.Form,), form_fields)

## `nautobot.apps.forms.parse_alphanumeric_range(string)`

Expand an alphanumeric range (continuous or not) into a list. 'a-d,f' => [a, b, c, d, f] '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]

Source code in `nautobot/core/forms/utils.py`

    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    def parse_alphanumeric_range(string):
        """
        Expand an alphanumeric range (continuous or not) into a list.
        'a-d,f' => [a, b, c, d, f]
        '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
        """
        values = []
        for dash_range in string.split(","):
            try:
                begin, end = dash_range.split("-")
                vals = begin + end
                # Break out of loop if there's an invalid pattern to return an error
                if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
                    return []
            except ValueError:
                begin, end = dash_range, dash_range
            if begin.isdigit() and end.isdigit():
                for n in list(range(int(begin), int(end) + 1)):
                    values.append(n)
            else:
                # Value-based
                if begin == end:
                    values.append(begin)
                # Range-based
                else:
                    # Not a valid range (more than a single character)
                    if not len(begin) == len(end) == 1:
                        raise django_forms.ValidationError(f'Range "{dash_range}" is invalid.')
                    for n in list(range(ord(begin), ord(end) + 1)):
                        values.append(chr(n))
        return values

## `nautobot.apps.forms.parse_numeric_range(string, base=10)`

Expand a numeric range (continuous or not) into a decimal or hexadecimal list, as specified by the base parameter '0-3,5' => [0, 1, 2, 3, 5] '2,8-b,d,f' => [2, 8, 9, a, b, d, f]

Source code in `nautobot/core/forms/utils.py`

    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    def parse_numeric_range(string, base=10):
        """
        Expand a numeric range (continuous or not) into a decimal or
        hexadecimal list, as specified by the base parameter
          '0-3,5' => [0, 1, 2, 3, 5]
          '2,8-b,d,f' => [2, 8, 9, a, b, d, f]
        """
        values = []
        if not string:
            return values
        for dash_range in string.split(","):
            try:
                begin, end = dash_range.split("-")
            except ValueError:
                begin, end = dash_range, dash_range
            begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
            values.extend(range(begin, end))
        return list(set(values))

## `nautobot.apps.forms.restrict_form_fields(form, user, action='view')`

Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects as available choices.

Source code in `nautobot/core/forms/utils.py`

    132
    133
    134
    135
    136
    137
    138
    139
    def restrict_form_fields(form, user, action="view"):
        """
        Restrict all form fields which reference a RestrictedQuerySet. This ensures that users see only permitted objects
        as available choices.
        """
        for field in form.fields.values():
            if hasattr(field, "queryset") and issubclass(field.queryset.__class__, querysets.RestrictedQuerySet):
                field.queryset = field.queryset.restrict(user, action)