Skip to content

Field Descriptors

nautobot_design_builder.fields

This module includes the proxy model field descriptors.

The way design builder handles attribute assignment is with descriptors. This allows assignment of values directly to the proxy model and the descriptors handle any necessary deferrals.

For instance, ForeignKey relationships require that foreign key object be present in the database before the receiving object (object with the ForeignKey field) can be saved. When design builder encounters this situation there are some cases (for example, setting IP addresses on interfaces) where assignment must be deferred in order to guarantee the parent object is present in the database prior to save.

Another example is the reverse side of foreign key relationships. Consider a Device which has many Interface objects. The device foreign key is defined on the Interface object, but we would typically model this in design builder as such:

devices:
  # additional attributes such as device type, role, location, etc
  # are not illustrated here.
  - name: "My Device"
    interfaces:
      - name: "Ethernet1"
        # type, status, etc
      - name: "Ethernet2"
        # type, status, etc

In order to save these objects to the database, the device must be saved first, and then each interface's device foreign key is set and saved.

Since design builder generally processes things in a depth first order, the natural sequence is for the interfaces (in the above example) to be created first. Therefore, the ManyToOneRelField will handle creating an instance of Interface but deferring database save until after the device is saved.

See also: https://docs.python.org/3/howto/descriptor.html

BaseModelField

Bases: ModelField

BaseModelField is backed by django.db.models.fields.Field.

BaseModelField is used as the base class for any design builder field which proxies to a Django model field. Not all design builder fields are for actual model database fields. For instance, custom relationships are something design builder handles with a field descriptor, but they are not backed by django Field descriptors.

Source code in nautobot_design_builder/fields.py
class BaseModelField(ModelField):  # pylint:disable=too-few-public-methods
    """`BaseModelField` is backed by django.db.models.fields.Field.

    `BaseModelField` is used as the base class for any design builder field
    which proxies to a Django model field. Not all design builder fields are
    for actual model database fields. For instance, custom relationships are
    something design builder handles with a field descriptor, but they
    are not backed by django `Field` descriptors.
    """

    field: django_models.Field
    related_model: Type[object]

    def __init__(self, field: django_models.Field):
        """Initialize a field proxy.

        Args:
            field (django_models.Field): The field that should be proxied on the django model.
        """
        self.field = field
        self.field_name = field.name
        self.related_model = field.related_model

__init__(field)

Initialize a field proxy.

Parameters:

Name Type Description Default
field Field

The field that should be proxied on the django model.

required
Source code in nautobot_design_builder/fields.py
def __init__(self, field: django_models.Field):
    """Initialize a field proxy.

    Args:
        field (django_models.Field): The field that should be proxied on the django model.
    """
    self.field = field
    self.field_name = field.name
    self.related_model = field.related_model

CustomRelationshipField

Bases: ModelField, RelationshipFieldMixin

This class models a Nautobot custom relationship.

When a design builder model class is created, custom relationships are retrieved for the underlying content-type. Each of these relationships is then added to the design builder proxy model as new properties. The property name is derived from the source or destination of the label, based on which side matches the underlying content-type. The relationship label will return the verbose_name_plural of the other side object if no label has been set.

It should be noted that if a custom relationship's label matches a built-in field, then the proxy model will use the built-in field and the custom relationship will not be accessible. Additionally, a warning will be logged that a potential naming conflict exists.

Source code in nautobot_design_builder/fields.py
class CustomRelationshipField(ModelField, RelationshipFieldMixin):  # pylint: disable=too-few-public-methods
    """This class models a Nautobot custom relationship.

    When a design builder model class is created, custom relationships are
    retrieved for the underlying content-type. Each of these relationships is
    then added to the design builder proxy model as new properties. The property
    name is derived from the source or destination of the label, based on which
    side matches the underlying content-type. The relationship label will return
    the verbose_name_plural of the other side object if no label has been set.

    It should be noted that if a custom relationship's label matches a built-in
    field, then the proxy model will use the built-in field and the custom
    relationship will not be accessible. Additionally, a warning will be logged
    that a potential naming conflict exists.
    """

    def __init__(self, model_class, relationship: Relationship):
        """Create a new custom relationship field.

        Args:
            model_class (Model): Model class for this relationship.
            relationship (Relationship): The Nautobot custom relationship backing this field.
        """
        self.relationship = relationship
        field_name = ""
        if self.relationship.source_type == ContentType.objects.get_for_model(model_class.model_class):
            self.related_model = relationship.destination_type.model_class()
            field_name = str(self.relationship.get_label("source")).lower()
        else:
            self.related_model = relationship.source_type.model_class()
            field_name = str(self.relationship.get_label("destination")).lower()
        if hasattr(model_class.model_class, field_name):
            raise FieldNameError(model_class, relationship, field_name)
        self.__set_name__(model_class, str_to_var_name(field_name))
        self.key_name = self.relationship.key

    @debug_set
    def __set__(self, obj: "ModelInstance", values):  # noqa:D105
        """Add an association between the created object and the given value.

        Args:
            obj: (ModelInstance): The object receiving this attribute setter.

            values (Model): The related objects to add.
        """

        def setter():
            for value in values:
                value = self._get_instance(obj, value)
                if value.design_metadata.created:
                    value.save()

                source = obj.design_instance
                destination = value.design_instance
                if self.relationship.source_type == ContentType.objects.get_for_model(destination):
                    source, destination = destination, source

                source_type = ContentType.objects.get_for_model(source)
                destination_type = ContentType.objects.get_for_model(destination)
                relationship_association = obj.design_metadata.create_child(
                    RelationshipAssociation,
                    attributes={
                        "relationship_id": self.relationship.id,
                        "source_id": source.id,
                        "source_type_id": source_type.id,
                        "destination_id": destination.id,
                        "destination_type_id": destination_type.id,
                    },
                )
                relationship_association.save()

        obj.connect("POST_INSTANCE_SAVE", setter)

__init__(model_class, relationship)

Create a new custom relationship field.

Parameters:

Name Type Description Default
model_class Model

Model class for this relationship.

required
relationship Relationship

The Nautobot custom relationship backing this field.

required
Source code in nautobot_design_builder/fields.py
def __init__(self, model_class, relationship: Relationship):
    """Create a new custom relationship field.

    Args:
        model_class (Model): Model class for this relationship.
        relationship (Relationship): The Nautobot custom relationship backing this field.
    """
    self.relationship = relationship
    field_name = ""
    if self.relationship.source_type == ContentType.objects.get_for_model(model_class.model_class):
        self.related_model = relationship.destination_type.model_class()
        field_name = str(self.relationship.get_label("source")).lower()
    else:
        self.related_model = relationship.source_type.model_class()
        field_name = str(self.relationship.get_label("destination")).lower()
    if hasattr(model_class.model_class, field_name):
        raise FieldNameError(model_class, relationship, field_name)
    self.__set_name__(model_class, str_to_var_name(field_name))
    self.key_name = self.relationship.key

__set__(obj, values)

Add an association between the created object and the given value.

Parameters:

Name Type Description Default
obj ModelInstance

(ModelInstance): The object receiving this attribute setter.

required
values Model

The related objects to add.

required
Source code in nautobot_design_builder/fields.py
@debug_set
def __set__(self, obj: "ModelInstance", values):  # noqa:D105
    """Add an association between the created object and the given value.

    Args:
        obj: (ModelInstance): The object receiving this attribute setter.

        values (Model): The related objects to add.
    """

    def setter():
        for value in values:
            value = self._get_instance(obj, value)
            if value.design_metadata.created:
                value.save()

            source = obj.design_instance
            destination = value.design_instance
            if self.relationship.source_type == ContentType.objects.get_for_model(destination):
                source, destination = destination, source

            source_type = ContentType.objects.get_for_model(source)
            destination_type = ContentType.objects.get_for_model(destination)
            relationship_association = obj.design_metadata.create_child(
                RelationshipAssociation,
                attributes={
                    "relationship_id": self.relationship.id,
                    "source_id": source.id,
                    "source_type_id": source_type.id,
                    "destination_id": destination.id,
                    "destination_type_id": destination_type.id,
                },
            )
            relationship_association.save()

    obj.connect("POST_INSTANCE_SAVE", setter)

ForeignKeyField

Bases: BaseModelField, RelationshipFieldMixin

ForeignKey relationship.

Source code in nautobot_design_builder/fields.py
class ForeignKeyField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """`ForeignKey` relationship."""

    @debug_set
    def __set__(self, obj: "ModelInstance", value):  # noqa: D105
        deferred = getattr(value, "deferred", False) or (isinstance(value, Mapping) and value.get("deferred", False))

        def setter():
            model_instance = self._get_instance(obj, value)
            if model_instance.design_metadata.created:
                model_instance.save()

            with change_log(obj, self.field.attname):
                setattr(obj.design_instance, self.field_name, model_instance.design_instance)

            if deferred:
                obj.design_instance.save(update_fields=[self.field_name])

        if deferred:
            obj.connect("POST_INSTANCE_SAVE", setter)
        else:
            setter()

GenericForeignKeyField

Bases: BaseModelField, RelationshipFieldMixin

Generic foreign key field.

Source code in nautobot_design_builder/fields.py
class GenericForeignKeyField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """Generic foreign key field."""

    @debug_set
    def __set__(self, obj: "ModelInstance", value):  # noqa:D105
        fk_field = self.field.fk_field
        ct_field = self.field.ct_field
        ct_id_field = obj.design_instance._meta.get_field(ct_field).attname
        with change_log(obj, fk_field), change_log(obj, ct_id_field):
            setattr(obj.design_instance, fk_field, value.design_instance.pk)
            setattr(obj.design_instance, ct_field, ContentType.objects.get_for_model(value.design_instance))

GenericRelField

Bases: BaseModelField, RelationshipFieldMixin

Field used as part of content-types generic relation.

Source code in nautobot_design_builder/fields.py
class GenericRelField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """Field used as part of content-types generic relation."""

    @debug_set
    def __set__(self, obj: "ModelInstance", value):  # noqa:D105
        with change_log(obj, self.field.attname):
            setattr(obj.design_instance, self.field.attname, self._get_instance(obj, value))

GenericRelationField

Bases: BaseModelField, RelationshipFieldMixin

Generic relationship field.

Source code in nautobot_design_builder/fields.py
class GenericRelationField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """Generic relationship field."""

    @debug_set
    def __set__(self, obj: "ModelInstance", values):  # noqa:D105
        if not isinstance(values, list):
            values = [values]
        items = []
        for value in values:
            value = self._get_instance(obj, value)
            if value.design_metadata.created:
                value.save()
            items.append(value.design_instance)
        with change_log(obj, self.field_name):
            getattr(obj.design_instance, self.field_name).add(*items)

ManyToManyField

Bases: BaseModelField, RelationshipFieldMixin

Many to many relationship field.

Source code in nautobot_design_builder/fields.py
class ManyToManyField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """Many to many relationship field."""

    def __init__(self, field: django_models.Field):  # noqa:D102,D107
        super().__init__(field)
        self.auto_through = True
        self._init_through()

    def _init_through(self):
        self.through = self.field.remote_field.through
        if not self.through._meta.auto_created:
            self.auto_through = False
            if self.field.remote_field.through_fields:
                self.link_field = self.field.remote_field.through_fields[0]
            else:
                for field in self.through._meta.fields:
                    if field.related_model == self.field.model:
                        self.link_field = field.name

    def _get_related_model(self, value):
        """Get the appropriate related model for the value.

        if there is an explicit through class, then we have two choices:
        1) Assign explicitly using the through-class attributes
        2) Assign implicitly like a normal many-to-many

        We want to be able to handle both situations, because it may be that
        the through class has additional attributes. The way we determine if
        the design is requesting the through-class or the implicit related class
        is by examining the values to be assigned and matching their keys with
        the related model and through model.
        """
        if isinstance(value, Mapping):
            attributes = set()
            # Extract all of the top-level field names from the query in order
            # to match them against available fields in the through table. If
            # the set of attributes is a subset of the through class's attributes
            # then use the through class directly, otherwise use the related_model
            # class
            for attribute in value.keys():
                if attribute.startswith("!get") or attribute.startswith("!create"):
                    attribute_parts = attribute.split(":")
                    attribute = attribute_parts[1]

                if "__" in attribute:
                    attribute = attribute.split("__")[0]
                attributes.add(attribute)
            through_fields = set(field.name for field in self.through._meta.fields)
            if self.auto_through is False and attributes.issubset(through_fields):
                return self.through, attributes.intersection(through_fields)
        return self.related_model, set()

    @debug_set
    def __set__(self, obj: "ModelInstance", values):  # noqa:D105
        def setter():
            items = []
            for value in values:
                related_model, through_fields = self._get_related_model(value)
                relationship_manager = getattr(obj.design_instance, self.field_name).model.objects
                if through_fields:
                    value[f"!create_or_update:{self.link_field}_id"] = str(obj.design_instance.id)
                    relationship_manager = self.through.objects

                for field in through_fields:
                    value[f"!create_or_update:{field}"] = value.pop(field)
                value = self._get_instance(obj, value, relationship_manager, related_model)
                if related_model is not self.through:
                    items.append(value.design_instance)
                else:
                    setattr(value.design_instance, self.link_field, obj.design_instance)
                if value.design_metadata.created:
                    value.save()
            if items:
                with change_log(obj, self.field_name):
                    getattr(obj.design_instance, self.field_name).add(*items)

        obj.connect("POST_INSTANCE_SAVE", setter)

ManyToManyRelField

Bases: ManyToManyField

Reverse many to many relationship field.

Source code in nautobot_design_builder/fields.py
class ManyToManyRelField(ManyToManyField):  # pylint:disable=too-few-public-methods
    """Reverse many to many relationship field."""

    def _init_through(self):
        self.through = self.field.through
        if not self.through._meta.auto_created:
            self.auto_through = False
            if self.field.through_fields:
                self.link_field = self.field.through_fields[0]
            else:
                for field in self.through._meta.fields:
                    if field.related_model == self.field.model:
                        self.link_field = field.name

ManyToOneRelField

Bases: BaseModelField, RelationshipFieldMixin

The reverse side of a ForeignKey relationship.

Source code in nautobot_design_builder/fields.py
class ManyToOneRelField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """The reverse side of a `ForeignKey` relationship."""

    @debug_set
    def __set__(self, obj: "ModelInstance", values):  # noqa:D105
        if not isinstance(values, list):
            raise DesignImplementationError("Many-to-one fields must be a list", obj)

        def setter():
            for value in values:
                value = self._get_instance(obj, value, getattr(obj, self.field_name))
                with change_log(value, self.field.field.attname):
                    setattr(value.design_instance, self.field.field.name, obj.design_instance)
                value.save()

        obj.connect("POST_INSTANCE_SAVE", setter)

ModelField

Bases: ABC

This represents any type of field (attribute or relationship) on a Nautobot model.

The design builder fields are descriptors that are used to build the correct relationship hierarchy of django models based on the design input. The fields are used to sequence saves and value assignments in the correct order.

Source code in nautobot_design_builder/fields.py
class ModelField(ABC):
    """This represents any type of field (attribute or relationship) on a Nautobot model.

    The design builder fields are descriptors that are used to build the
    correct relationship hierarchy of django models based on the design input.
    The fields are used to sequence saves and value assignments in the correct
    order.
    """

    field_name: str

    def __set_name__(self, owner, name):  # noqa: D105
        self.field_name = name

    def __get__(self, obj, objtype=None) -> Any:
        """Retrieve the field value.

        In the event `obj` is None (as in getting the attribute from the class) then
        get the descriptor itself.

        Args:
            obj (ModelInstance): The model to retrieve the field value
            objtype (type, optional): The owning class of the descriptor. Defaults to None.

        Returns:
            Any: Either the descriptor instance or the field value.
        """
        if obj is None or obj.design_instance is None:
            return self
        return getattr(obj.design_instance, self.field_name)

    @abstractmethod
    def __set__(self, obj: "ModelInstance", value):
        """Method used to set the value of the field.

        Args:
            obj: (ModelInstance): The model to update.
            value (Any): Value that should be set on the model field.
        """

__get__(obj, objtype=None)

Retrieve the field value.

In the event obj is None (as in getting the attribute from the class) then get the descriptor itself.

Parameters:

Name Type Description Default
obj ModelInstance

The model to retrieve the field value

required
objtype type

The owning class of the descriptor. Defaults to None.

None

Returns:

Name Type Description
Any Any

Either the descriptor instance or the field value.

Source code in nautobot_design_builder/fields.py
def __get__(self, obj, objtype=None) -> Any:
    """Retrieve the field value.

    In the event `obj` is None (as in getting the attribute from the class) then
    get the descriptor itself.

    Args:
        obj (ModelInstance): The model to retrieve the field value
        objtype (type, optional): The owning class of the descriptor. Defaults to None.

    Returns:
        Any: Either the descriptor instance or the field value.
    """
    if obj is None or obj.design_instance is None:
        return self
    return getattr(obj.design_instance, self.field_name)

__set__(obj, value) abstractmethod

Method used to set the value of the field.

Parameters:

Name Type Description Default
obj ModelInstance

(ModelInstance): The model to update.

required
value Any

Value that should be set on the model field.

required
Source code in nautobot_design_builder/fields.py
@abstractmethod
def __set__(self, obj: "ModelInstance", value):
    """Method used to set the value of the field.

    Args:
        obj: (ModelInstance): The model to update.
        value (Any): Value that should be set on the model field.
    """

RelationshipFieldMixin

Field mixin for relationships to other models.

RelationshipField instances represent fields that have some sort of relationship to other objects. These include ForeignKey and ManyToMany. Relationship fields also include the reverse side of fields or even custom relationships.

Source code in nautobot_design_builder/fields.py
class RelationshipFieldMixin:  # pylint:disable=too-few-public-methods
    """Field mixin for relationships to other models.

    `RelationshipField` instances represent fields that have some sort of relationship
    to other objects. These include `ForeignKey` and `ManyToMany`.
    Relationship fields also include the reverse side of fields or even custom relationships.
    """

    def _get_instance(
        self, obj: "ModelInstance", value: Any, relationship_manager: django_models.Manager = None, related_model=None
    ):
        """Helper function to create a new child model from a value.

        If the passed-in value is a dictionary, this method assumes that the dictionary
        represents a new design builder object which will belong to a parent. In this
        case a new child is created from the parent object.

        If the value is not a dictionary, it is simply returned.

        Args:
            obj (ModelInstance): The parent object that the value will be ultimately assigned.

            value (Any): The value being assigned to the parent object.

            relationship_manager (Manager, optional): This argument can be used to restrict the
              child object lookups to a subset. For instance, the `interfaces` manager on a `Device`
              instance will restrict queries interfaces where their foreign key is set to the device.
              Defaults to None.

            related_model: The model class to use for creating new children. Defaults to the
              field's related model.

        Returns:
            ModelInstance: Either a newly created `ModelInstance` or the original value.
        """
        if related_model is None:
            related_model = self.related_model
        if isinstance(value, Mapping):
            value = obj.design_metadata.create_child(related_model, value, relationship_manager)
        return value

SimpleField

Bases: BaseModelField

A field that accepts a scalar value.

SimpleField will immediately set scalar values on the underlying field. This includes assignment to fields such as CharField or IntegerField. When this descriptor is called, the assigned value is immediately passed to the underlying model object.

Source code in nautobot_design_builder/fields.py
class SimpleField(BaseModelField):  # pylint:disable=too-few-public-methods
    """A field that accepts a scalar value.

    `SimpleField` will immediately set scalar values on the underlying field. This
    includes assignment to fields such as `CharField` or `IntegerField`. When
    this descriptor is called, the assigned value is immediately passed to the
    underlying model object.
    """

    @debug_set
    def __set__(self, obj: "ModelInstance", value):  # noqa: D105
        with change_log(obj, self.field_name):
            setattr(obj.design_instance, self.field_name, value)

TagField

Bases: BaseModelField, RelationshipFieldMixin

Taggit field.

Source code in nautobot_design_builder/fields.py
class TagField(BaseModelField, RelationshipFieldMixin):  # pylint:disable=too-few-public-methods
    """Taggit field."""

    def __init__(self, field: django_models.Field):  # noqa:D102,D107
        super().__init__(field)
        self.related_model = field.remote_field.model

    def __set__(self, obj: "ModelInstance", values):  # noqa:D105
        # I hate that this code is almost identical to the ManyToManyField
        # __set__ code, but I don't see an easy way to DRY it up at the
        # moment.
        def setter():
            items = []
            for value in values:
                value = self._get_instance(obj, value, getattr(obj.design_instance, self.field_name))
                if value.design_metadata.created:
                    value.save()
                items.append(value.design_instance)
            if items:
                with change_log(obj, self.field_name):
                    getattr(obj.design_instance, self.field_name).add(*items)

        obj.connect("POST_INSTANCE_SAVE", setter)

field_factory(arg1, arg2)

Factory function to create a ModelField.

Source code in nautobot_design_builder/fields.py
def field_factory(arg1, arg2) -> ModelField:
    """Factory function to create a ModelField."""
    if isinstance(arg2, Relationship):
        return CustomRelationshipField(arg1, arg2)

    field = None
    if not arg2.is_relation:
        field = SimpleField(arg2)
    elif isinstance(arg2, ct_fields.GenericRelation):
        field = GenericRelationField(arg2)
    elif isinstance(arg2, ct_fields.GenericRel):
        field = GenericRelField(arg2)
    elif isinstance(arg2, ct_fields.GenericForeignKey):
        field = GenericForeignKeyField(arg2)
    elif isinstance(arg2, TaggableManager):
        field = TagField(arg2)
    elif isinstance(arg2, django_models.ForeignKey):
        field = ForeignKeyField(arg2)
    elif isinstance(arg2, django_models.ManyToOneRel):
        field = ManyToOneRelField(arg2)
    elif isinstance(arg2, django_models.ManyToManyField):
        field = ManyToManyField(arg2)
    elif isinstance(arg2, django_models.ManyToManyRel):
        field = ManyToManyRelField(arg2)
    else:
        raise DesignImplementationError(f"Cannot manufacture field for {type(arg2)}, {arg2} {arg2.is_relation}")
    return field