Developing Nautobot Plugins – Part 4

This is part 4 of the tutorial series on writing Nautobot plugins. Nautobot plugins are a way to extend the base functionality of Nautobot. Plugins can extend the database schema, add custom pages, and even update existing pages within Nautobot; the possibilities are nearly endless. In this blog series, we are developing a plugin for modeling and managing DNS zone data within Nautobot. In the previous posts, we covered setting up a development environment (part 1), creating models, views and navigation (part 2), and creating forms and tables (part 3). In this post, we will create filters used in GUI views and API calls. We will also add a search panel to the GUI list views for our models.

For coding along with this blog post, please clone part 3 of nautobot-example-dns-manager and use that as a starting point.

The completed part 4 version of nautobot-example-dns-manager is also available as a reference.

Defining Filters

To implement filtering of the records used by our plugin we will create FilterSet classes for model classes. FilterSet classes provide a mechanism for searching through database records and returning only those that matched the constraints defined by the operator.

FilterSet classes used by the plugin are placed in the filters.py file. By the Nautobot convention, we name FilterSet classes by appending FilterSet to the name of the model class. For example, filter class for DnsZoneModel will be named DnsZoneModelFilterSet. Note that the internal machinery of Nautobot, including unit test helpers, expects the filter classes to follow this convention.

We will start with the FilterSet class for DnsZoneModel:

class DnsZoneModelFilterSet(NautobotFilterSet):
    """Filter for filtering DnsZoneModel objects."""

    q = SearchFilter(
        filter_predicates={
            "name": "icontains",
            "mname": "icontains",
            "rname": "icontains",
        },
    )
    ttl__gte = django_filters.NumberFilter(field_name="ttl", lookup_expr="gte")
    ttl__lte = django_filters.NumberFilter(field_name="ttl", lookup_expr="lte")

    class Meta:
        model = DnsZoneModel
        fields = "__all__"

Let’s break this code down.

We follow Nautobot’s best practices, defined here, and ask for filters to be generated automatically for all the fields defined on the model:

    class Meta:
        model = DnsZoneModel
        fields = "__all__"

Next, we create an additional filter named q. By convention, the q filter is used for free text search and is placed as the first filter on the list of filters in the GUI. To define this filter we use the SearchFilter helper class provided by Nautobot for this use case.

from nautobot.utilities.filters import SearchFilter

This class expects an argument named filter_predicates, which is a dictionary with keys being field names of the model that we want to be searched. Corresponding key values define the field lookup type that will be applied to the field values when searching. We will use an icontains lookup, which performs a case-insensitive way to see whether any field contains the search term.

Below is the completed code for the q field:

    q = SearchFilter(
        filter_predicates={
            "name": "icontains",
            "mname": "icontains",
            "rname": "icontains",
        },
    )

See Django field lookups docs for a full list of available filter lookups.

We will also add extra filters for ttl field, one for searching ttl equal to or greater than some value, and one for less than or equal to some value.

To do that we explicitly define two new filters, ttl__gte and ttl__lte. These will use the django_filters.NumberFilter type, which is for filtering numeric values. For each field, we need to define which underlying model attribute we are mapping to; in this case it is ttl. We also need to specify the lookup expression that will be applied to each of the filters. This is done by assigning the expression name to the lookup_expr argument. In our case, these expressions are gte and lte.

    ttl__gte = django_filters.NumberFilter(field_name="ttl", lookup_expr="gte")
    ttl__lte = django_filters.NumberFilter(field_name="ttl", lookup_expr="lte")

This completes the FilterForm for the DnsZoneModel:

class DnsZoneModelFilterSet(NautobotFilterSet):
    """Filter for filtering DnsZoneModel objects."""

    q = SearchFilter(
        filter_predicates={
            "name": "icontains",
            "mname": "icontains",
            "rname": "icontains",
        },
    )
    ttl__gte = django_filters.NumberFilter(field_name="ttl", lookup_expr="gte")
    ttl__lte = django_filters.NumberFilter(field_name="ttl", lookup_expr="lte")

    class Meta:
        model = DnsZoneModel
        fields = "__all__"

CNameRecordModel and ARecordModel link to DnsZoneModel via the zone attribute. For the filtering to work correctly for this field we need to make this lookup use the NaturalKeyOrPKMultipleChoiceFilter class. This lookup type needs a queryset argument to know which model instances it should be filtering against. In our case, it is DnsZoneModel; so we provide the queryset that returns all instances of this model.

We also define a label that tells the user this filter takes slug or id.

Finalized filter field for zone attribute:

zone = NaturalKeyOrPKMultipleChoiceFilter(
    queryset=DnsZoneModel.objects.all(),
    label="DNS Zone (slug or ID)",
)

The other fields and definitions replicate the code we wrote for DnsZoneModelFilterSet. Using that code we complete the ARecordModelFilterSet and CNameRecordModelFilterSet classes:

class ARecordModelFilterSet(NautobotFilterSet):
    """Filter for filtering ARecordModel objects."""

    q = SearchFilter(
        filter_predicates={
            "name": "icontains",
        },
    )
    zone = NaturalKeyOrPKMultipleChoiceFilter(
        queryset=DnsZoneModel.objects.all(),
        label="DNS Zone (slug or ID)",
    )
    ttl__gte = django_filters.NumberFilter(field_name="ttl", lookup_expr="gte")
    ttl__lte = django_filters.NumberFilter(field_name="ttl", lookup_expr="lte")

    class Meta:
        model = ARecordModel
        fields = "__all__"
class CNameRecordModelFilterSet(NautobotFilterSet):
    """Filter for filtering CNameRecordModel objects."""

    q = SearchFilter(
        filter_predicates={
            "name": "icontains",
            "value": "icontains",
        },
    )
    zone = NaturalKeyOrPKMultipleChoiceFilter(
        queryset=DnsZoneModel.objects.all(),
        label="DNS Zone (slug or ID)",
    )
    ttl__gte = django_filters.NumberFilter(field_name="ttl", lookup_expr="gte")
    ttl__lte = django_filters.NumberFilter(field_name="ttl", lookup_expr="lte")

    class Meta:
        model = CNameRecordModel
        fields = "__all__"

The final step needed for FilterSet to take effect is to point to the newly defined classes from the UIViewSet classes.

We do this by assigning each of the FilterSet classes to the filterset_class class attribute of the corresponding UIViewSet class.

For example, here we add FilterSet class to the DnsZoneModelUIViewSet view:

class DnsZoneModelUIViewSet(
    view_mixins.ObjectListViewMixin,
    view_mixins.ObjectDetailViewMixin,
    view_mixins.ObjectEditViewMixin,
    view_mixins.ObjectDestroyViewMixin,
    view_mixins.ObjectBulkDestroyViewMixin,
):
    queryset = DnsZoneModel.objects.all()
    table_class = DnsZoneModelTable
    form_class = DnsZoneModelForm
    serializer_class = serializers.DnsZoneModelSerializer
    filterset_class = DnsZoneModelFilterSet

Building Filter Forms

We have completed FilterSet classes, which define the filtering logic. To expose filtering in the GUI, we need to create FilterForm classes for each of our models.

Filter form describes how the form will appear in the GUI and will handle input validation before passing the values to the filtering logic.

By convention, we create FilterForm classes in the forms.py file. The class names should follow the <ModelClassName>FilterForm format. For example, DnsZoneModelFilterForm is the FilterForm class we will define for the DnsZoneModel model.

In the FilterForm class we define the model class the form is meant for. We then define each of the model fields we want to expose in the form. These fields will have to be assigned a form field type that matches the field type defined in the model.

For example, the name field, which is a models.CharField on the model, becomes forms.CharField in the form.

Selecting the correct form field class is important, as it will present the operator with the matching UI field. It will also define the validation logic applied before the entered value is passed to the filter sets.

Form field classes are listed in the Form Fields Django docs.

Let’s define the filter form class for DnsZoneModel, and then we’ll walk through the code.

class DnsZoneModelFilterForm(NautobotFilterForm):
    """Filtering/search form for `DnsZoneModelForm` objects."""

    model = DnsZoneModel

    q = forms.CharField(required=False, label="Search")
    name = forms.CharField(required=False)
    mname = forms.CharField(required=False, label="Primary server")
    rname = forms.EmailField(required=False, label="Admin email")
    refresh = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Refresh timer")
    retry = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Retry timer")
    expire = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Expiry timer")
    ttl = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Time to Live")
    ttl__gte = forms.IntegerField(required=False, label="TTL Greater/equal than")
    ttl__lte = forms.IntegerField(required=False, label="TTL Less/equal than")

Form field class initializers take arguments; some are shared across all types, and some are type specific.

We don’t want any fields to be required by default, so we’ll pass the argument required=False to the field initializers.

We will also define custom labels to replace auto-generated ones, which by default use the name of the model field. Labels are provided to the label argument.

For IntegerField fields, it’s a good idea to provide minimum and maximum allowed values if the model defines them. This will provide additional validation of the values at a UI layer. To do that, use min_value and max_value arguments in the IntegerField initializers.

Finally, notice that we included ttl__gte and ttl__lte fields, which match the custom filter fields defined earlier.

Form classes for ARecordModel and CNameRecordModel follow a similar pattern. The one difference is the zone field, which we want to be a multiple-choice field to allow an operator to select one or more DnsZoneModel instances to filter against.

This is done by defining the zone field to be of the DynamicModelMultipleChoiceField type provided by Nautobot in nautobot.utilities.forms. Choices presented in the GUI are provided by the queryset defined in the queryset argument. Here we want all of the DnsZoneModel instances to be available, so we use the DnsZoneModel.objects.all() queryset.

Additionally, we want the value of slug field to be used in the queries. By providing slug as the value to the to_field_name argument, we change the default (which is to use the model’s primary key). It’s important that the value of the field chosen for this purpose is unique for each instance of the model.

With zone form field defined, we complete FilterForm classes for ARecordModel and CNameRecordModel:

class ARecordModelFilterForm(NautobotFilterForm):
    """Filtering/search form for `ARecordModelForm` objects."""

    model = ARecordModel

    q = forms.CharField(required=False, label="Search")
    name = forms.CharField(required=False)
    zone = DynamicModelMultipleChoiceField(required=False, queryset=DnsZoneModel.objects.all(), to_field_name="slug")
    ttl = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Time to Live")
    ttl__gte = forms.IntegerField(required=False, label="TTL Greater/equal than")
    ttl__lte = forms.IntegerField(required=False, label="TTL Less/equal than")
class CNameRecordModelFilterForm(NautobotFilterForm):
    """Filtering/search form for `CNameRecordModelForm` objects."""

    model = CNameRecordModel

    q = forms.CharField(required=False, label="Search")
    name = forms.CharField(required=False)
    zone = DynamicModelMultipleChoiceField(required=False, queryset=DnsZoneModel.objects.all(), to_field_name="slug")
    value = forms.CharField(required=False, label="Redirect FQDN")
    ttl = forms.IntegerField(required=False, min_value=300, max_value=2147483647, label="Time to Live")
    ttl__gte = forms.IntegerField(required=False, label="TTL Greater/equal than")
    ttl__lte = forms.IntegerField(required=False, label="TTL Less/equal than")

Once we have our FilterForm classes defined, we need to link them to the corresponding UIViewSet class.

We do it by assigning FilterSet class to the filterset_form_class class attribute of the corresponding UIViewSet class.

UIViewSet classes, including FilterSet and FilterForm references:

class ARecordModelUIViewSet(
    view_mixins.ObjectListViewMixin,
    view_mixins.ObjectDetailViewMixin,
    view_mixins.ObjectEditViewMixin,
    view_mixins.ObjectDestroyViewMixin,
    view_mixins.ObjectBulkDestroyViewMixin,
):
    queryset = ARecordModel.objects.all()
    table_class = ARecordModelTable
    form_class = ARecordModelForm
    serializer_class = serializers.ARecordModelSerializer
    filterset_class = ARecordModelFilterSet
    filterset_form_class = ARecordModelFilterForm
class CNameRecordModelUIViewSet(
    view_mixins.ObjectListViewMixin,
    view_mixins.ObjectDetailViewMixin,
    view_mixins.ObjectEditViewMixin,
    view_mixins.ObjectDestroyViewMixin,
    view_mixins.ObjectBulkDestroyViewMixin,
):
    queryset = CNameRecordModel.objects.all()
    table_class = CNameRecordModelTable
    form_class = CNameRecordModelForm
    serializer_class = serializers.CNameRecordModelSerializer
    filterset_class = CNameRecordModelFilterSet
    filterset_form_class = CNameRecordModelFilterForm
class DnsZoneModelUIViewSet(
    view_mixins.ObjectListViewMixin,
    view_mixins.ObjectDetailViewMixin,
    view_mixins.ObjectEditViewMixin,
    view_mixins.ObjectDestroyViewMixin,
    view_mixins.ObjectBulkDestroyViewMixin,
):
    queryset = DnsZoneModel.objects.all()
    table_class = DnsZoneModelTable
    form_class = DnsZoneModelForm
    serializer_class = serializers.DnsZoneModelSerializer
    filterset_class = DnsZoneModelFilterSet
    filterset_form_class = DnsZoneModelFilterForm

Filtering in GUI

With all the code in place, we start Nautobot in our local dev environment and navigate to the list views for models.

For each of the models, you should see a view similar to the ones below. Notice the search panel on the right-hand side with the fields we defined.

References


Conclusion
In this blog post, we learned how to build filtering logic for models defined in our plugin. We then exposed these filters using the search form displayed in the list view for each of the models. In the next installment of this series, we will learn how to add REST and GraphQL APIs to the plugin.
-Przemek


ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Author