Skip to content

nautobot.apps.forms

Forms and fields for apps to use.

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/utilities/forms/forms.py
class BulkEditForm(forms.Form):
    """
    Base form for editing multiple objects in bulk.

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

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

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

nautobot.apps.forms.CSVModelChoiceField

Bases: forms.ModelChoiceField

Provides additional validation for model choices entered as CSV data.

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

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

    def to_python(self, value):
        try:
            return super().to_python(value)
        except MultipleObjectsReturned:
            raise forms.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 in CSV format.

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

    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.CustomFieldModelCSVForm

Bases: CSVModelForm, CustomFieldModelFormMixin

Base class for CSV export of models that support custom fields.

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

    def _append_customfield_fields(self):

        # Append form fields
        for cf in CustomField.objects.filter(content_types=self.obj_type):
            field_name = f"cf_{cf.slug}"
            self.fields[field_name] = cf.to_form_field(for_csv_import=True)

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

nautobot.apps.forms.CustomFieldModelFormMixin

Bases: forms.ModelForm

Source code in nautobot/extras/forms/mixins.py
class CustomFieldModelFormMixin(forms.ModelForm):
    def __init__(self, *args, **kwargs):

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

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

        self._append_customfield_fields()

    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 = f"cf_{cf.slug}"
            if self.instance.present_in_database:
                self.fields[field_name] = cf.to_form_field(set_initial=False)
                # 2.0 TODO: #824 self.instance.cf.get(cf.slug)
                self.fields[field_name].initial = self.instance.cf.get(cf.name)
            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:
            # 2.0 TODO: #824 will let us just do:
            # self.instance.cf[field_name[3:]] = self.cleaned_data.get(field_name)
            # but for now we need:
            cf = CustomField.objects.get(slug=field_name[3:])
            self.instance.cf[cf.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/utilities/forms/widgets.py
class DatePicker(forms.TextInput):
    """
    Date picker using Flatpickr.
    """

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

nautobot.apps.forms.DateTimePicker

Bases: forms.TextInput

DateTime picker using Flatpickr.

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

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

nautobot.apps.forms.DynamicModelChoiceField

Bases: DynamicModelChoiceMixin, 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/utilities/forms/fields.py
class DynamicModelChoiceField(DynamicModelChoiceMixin, 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/utilities/forms/fields.py
def clean(self, value):
    """
    When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
    string 'null'.  This will check for that condition and gracefully handle the conversion to a NoneType.
    """
    if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE:
        return None
    return super().clean(value)

nautobot.apps.forms.DynamicModelMultipleChoiceField

Bases: DynamicModelChoiceMixin, forms.ModelMultipleChoiceField

A multiple-choice version of DynamicModelChoiceField.

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

    filter = django_filters.ModelMultipleChoiceFilter
    widget = widgets.APISelectMultiple

    def prepare_value(self, value):
        """
        Ensure that a single string value (i.e. UUID) is accurately represented as a list of one item.

        This is necessary because otherwise the superclass will split the string into individual characters,
        resulting in an error (https://github.com/nautobot/nautobot/issues/512).

        Note that prepare_value() can also be called with an object instance or list of instances; in that case,
        we do *not* want to convert a single instance to a list of one entry.
        """
        if isinstance(value, str):
            value = [value]
        return super().prepare_value(value)

prepare_value(value)

Ensure that a single string value (i.e. UUID) is accurately represented as a list of one item.

This is necessary because otherwise the superclass will split the string into individual characters, resulting in an error (https://github.com/nautobot/nautobot/issues/512).

Note that prepare_value() can also be called with an object instance or list of instances; in that case, we do not want to convert a single instance to a list of one entry.

Source code in nautobot/utilities/forms/fields.py
def prepare_value(self, value):
    """
    Ensure that a single string value (i.e. UUID) is accurately represented as a list of one item.

    This is necessary because otherwise the superclass will split the string into individual characters,
    resulting in an error (https://github.com/nautobot/nautobot/issues/512).

    Note that prepare_value() can also be called with an object instance or list of instances; in that case,
    we do *not* want to convert a single instance to a list of one entry.
    """
    if isinstance(value, str):
        value = [value]
    return super().prepare_value(value)

nautobot.apps.forms.NautobotBulkEditForm

Bases: BootstrapMixin, CustomFieldModelBulkEditFormMixin, RelationshipModelBulkEditFormMixin, NoteModelBulkEditFormMixin

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

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

nautobot.apps.forms.NautobotModelForm

Bases: BootstrapMixin, CustomFieldModelFormMixin, RelationshipModelFormMixin, NoteModelFormMixin

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

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

nautobot.apps.forms.NoteModelBulkEditFormMixin

Bases: BulkEditForm, NoteFormBase

Bulk-edit form mixin for models that support Notes.

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

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

nautobot.apps.forms.RelationshipModelBulkEditFormMixin

Bases: BulkEditForm

Bulk-edit form mixin for models that support Relationships.

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

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

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

        self._append_relationships()

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

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

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

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

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

            # If this model is on the "source" side of the relationship, then the field will be named
            # "cr_<relationship-slug>__destination" since it's used to pick the destination object(s).
            # If we're on the "destination" side, the field will be "cr_<relationship-slug>__source".
            # For a symmetric relationship, both sides are "peer", so the field will be "cr_<relationship-slug>__peer"
            field_name = f"cr_{relationship.slug}__{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.slug}__{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_slugs = []
        for field, errors in required_objects_errors.items():
            self.add_error(None, errors)
            relationship_slug = field.split("__")[0][3:]
            already_invalidated_slugs.append(relationship_slug)

        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(
            slug__in=already_invalidated_slugs
        ):
            required_relationships.append(
                {
                    "slug": relationship.slug,
                    "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['slug']}__{required_relationship['required_side']}"

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

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

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

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

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

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

        relationship_data_errors = {}

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

        for relationship_message, object_list in relationship_data_errors.items():

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

        return super().clean()

save_relationships(*, instance, nullified_fields)

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

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

    for side, relationships_data in instance_relationships.items():
        peer_side = RelationshipSideChoices.OPPOSITE[side]
        for relationship, relationshipassociation_queryset in relationships_data.items():
            field_name = f"cr_{relationship.slug}__{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.RelationshipModelFormMixin

Bases: forms.ModelForm

Source code in nautobot/extras/forms/mixins.py
class RelationshipModelFormMixin(forms.ModelForm):
    def __init__(self, *args, **kwargs):

        self.obj_type = ContentType.objects.get_for_model(self._meta.model)
        self.relationships = []
        super().__init__(*args, **kwargs)

        self._append_relationships()

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

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

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

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

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

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

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

        super().clean()

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

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

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

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

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

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

                association.clean()
                association.save()

    def save(self, commit=True):

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

        return obj

clean()

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

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

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

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

Bases: forms.Form

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

Source code in nautobot/extras/forms/mixins.py
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.StatusModelCSVFormMixin

Bases: CSVModelForm

Mixin to add a required status choice field to CSV import forms.

Source code in nautobot/extras/forms/mixins.py
class StatusModelCSVFormMixin(CSVModelForm):
    """Mixin to add a required `status` choice field to CSV import forms."""

    status = CSVModelChoiceField(
        queryset=Status.objects.all(),
        to_field_name="slug",
        help_text="Operational status",
    )

nautobot.apps.forms.TagFilterField

Bases: forms.MultipleChoiceField

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/utilities/forms/fields.py
class TagFilterField(forms.MultipleChoiceField):
    """
    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
    """

    widget = widgets.StaticSelect2Multiple

    def __init__(self, model, *args, **kwargs):
        def get_choices():
            tags = model.tags.annotate(count=Count("extras_taggeditem_items")).order_by("name")
            return [(str(tag.slug), f"{tag.name} ({tag.count})") for tag in tags]

        # Choices are fetched each time the form is initialized
        super().__init__(label="Tags", choices=get_choices, required=False, *args, **kwargs)

nautobot.apps.forms.TimePicker

Bases: forms.TextInput

Time picker using Flatpickr.

Source code in nautobot/utilities/forms/widgets.py
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/utilities/forms/utils.py
def add_blank_choice(choices):
    """
    Add a blank choice to the beginning of a choices list.
    """
    return ((None, "---------"),) + tuple(choices)