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
Tags :
Contact Us to Learn More
Share details about yourself & someone from our team will reach out to you ASAP!