Developing Nautobot Plugins – Part 3

This post is the third installment in a series on how to write plugins for Nautobot. 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 will be developing a plugin for modeling and managing DNS zone data within Nautobot. In part 1 we covered setting up a development environment, and in part 2 we created models and views.

This post will provide a breakdown of how to use forms and tables inside your plugin. At a high level the purpose of forms and tables is to allow us to easily interact with our new models and perform CRUD (Create, Read, Update, and Delete) operations.

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

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

Forms Are Required to Add Objects

The first thing we want to do with our new DNS models is create some objects. In order to do this we need to create a form that will enable us to create a new DnsZoneModel object. We’ll start with the DNS Zone model since the ARecordModel and CNameRecordModel in our DNS manager plugin will need to be associated with a DNS Zone.

There are two places in the web GUI that give us access to create a new object. The first is a plus button in the navigation bar menu under IPAM > DNS > DNS Zones. The second is an add button that will appear on the table list view for DNS Zone objects. Let’s use the add button in the navigation bar first.

This button is present because in part 2 we included an add button in our navbar in nautobot_example_dns_manager/navigation.py.

The link matches our app name and follows the naming convention plugins:<app_name>:<url_prefix>_add

# nautobot_example_dns_manager/navigation.py

NavMenuItem(
                        link="plugins:nautobot_example_dns_manager:dnszonemodel_list",
                        name="DNS Zones",
                        permissions=[],
                        buttons=(
                            NavMenuAddButton(
                                link="plugins:nautobot_example_dns_manager:dnszonemodel_add",
                                permissions=[],
                            ),
                        ),
                    ),

How do we know what the URL prefix should be? This is set in nautobot_example_dns_manager/urls.py when we register the view as a URL pattern.

# nautobot_example_dns_manager/urls.py
...
router.register("dnszonemodel", views.DnsZoneModelUIViewSet) # "dnszonemodel" is our URL prefix

urlpatterns = []
urlpatterns += router.urls

If we start our development server and click the plus button under IPAM > DNS > DNS Zones, we will get this error.

TypeError at /plugins/example-dns-manager/dnszonemodel/add/
'NoneType' object is not callable

We’re getting this error because our plugin is missing the form class for this model, which is required to create or update objects. Let’s create a form class for this.

Creating the Form Class for DnsZoneModel

Create a new file in nautobot_example_dns_manager/ called forms.py.

At the top of the file include a short description and a couple of imports.

# nautobot_example_dns_manager/forms.py

"""Nautobot Example DNS Manager Forms."""

from nautobot.extras.forms import NautobotModelForm
from nautobot.utilities.forms import SlugField

from .models import DnsZoneModel

NautobotModelForm is the standard form to use when creating a model form, and we also need the SlugField class from nautobot.utilities.forms to use within our form.

Forms depend on models, so we also need to import our DnsZoneModel we created in part 2 from .models.

The form will inherit from NautobotModelForm and should have an instance of SlugField class assigned to the slug class attribute. We also need a Meta class inside our form that will tell Nautobot which model to link the form to and which fields to allow a user to populate.

# nautobot_example_dns_manager/forms.py

class DnsZoneModelForm(NautobotModelForm):
    slug = SlugField()

    class Meta:
        model = DnsZoneModel
        fields = [
            "name",
            "slug",
            "mname",
            "rname",
            "ttl",
            "refresh",
            "retry",
            "expire",
        ]

The next step before we can actually add an item is to assign the form to our view set in nautobot_example_dns_manager/views.py.

First, inside views.py import our new form.

# nautobot_example_dns_manager/views.py

from .forms import DnsZoneModelForm

Next, inside DnsZoneModelUIViewSet assign the form class to our view set.

# nautobot_example_dns_manager/views.py

...
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 # Assign our form class to the ViewSet here
...

After saving all changes and restarting the development server, we should now be able to click on the plus button again in the navigation bar for DNS Zones and see a form that allows us to create a new DNS Zone. We can see that the form has automatically added some items from the DnsZoneModel like help text and defaults. If we enter a new zone name, we can also see that the slug field is auto-populated for us. This is because of the line slug = SlugField() in our form class.

If we try to create the object now, we will get the following error:

AssertionError at /plugins/example-dns-manager/dnszonemodel/example-com
'DnsZoneModelUIViewSet' should either include a `serializer_class` attribute, or override the `get_serializer_class()` method.

Create an API Serializer for Our DnsZoneModel Form

One of the caveats of using Nautobot ViewSets is we must have a serializer class created and defined for the create and update views to be fully functional. The specific purpose and functions of the serializer and api views and urls modules are beyond the scope of this blog post and will be covered later. For now, we will create a very basic api serializer for the DnsZoneModel.

Create the following structure in our project:

└── nautobot_example_dns_manager
    └── api
        ├── __init__.py
        ├── serializers.py
        ├── urls.py
        └── views.py

__init__.py can be a blank file and is present to indicate to Python that the api folder is a package.

serializers.py should contain the following:

# nautobot_example_dns_manager/api/serializers.py

from nautobot.extras.api.serializers import NautobotModelSerializer

from nautobot_example_dns_manager.models import DnsZoneModel


class DnsZoneModelSerializer(NautobotModelSerializer):
    class Meta:
        model = DnsZoneModel
        fields = "__all__"

urls.py should contain the following:

# nautobot_example_dns_manager/api/urls.py

from nautobot.core.api import OrderedDefaultRouter

from . import views

router = OrderedDefaultRouter()
router.register("dnszonemodel", views.DnsZoneModelViewSet)

urlpatterns = router.urls

And views.py should contain the following:

# nautobot_example_dns_manager/api/views.py

from nautobot.extras.api.views import NautobotModelViewSet

from nautobot_example_dns_manager.models import DnsZoneModel
from . import serializers


class DnsZoneModelViewSet(NautobotModelViewSet):
    queryset = DnsZoneModel.objects.all()
    serializer_class = serializers.DnsZoneModelSerializer

The final step is to get the serializer class defined in DnsZoneModelUIViewSet in nautobot_example_dns_manager/views.py.

Import the serializer module:

# nautobot_example_dns_manager/views.py

from .api import serializers

And add the following line to the DnsZoneModelUIViewSet class:

# nautobot_example_dns_manager/views.py

...
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 # Add our new serializer class to the ViewSet
    ...

The first 20 lines of nautobot_example_dns_manager/views.py should now look like this:

# nautobot_example_dns_manager/views.py

"""Nautobot Example DNS Manager Views."""

from nautobot.core.views import mixins as view_mixins

from .models import DnsZoneModel, ARecordModel, CNameRecordModel
from .tables import DnsZoneModelTable, ARecordModelTable, CNameRecordModelTable
from .forms import DnsZoneModelForm
from .api import serializers


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

Once these files and changes are in place and the development server has restarted we can create our first DNS Zone object. Well, in this case it’s our second. Even though we were greeted with a traceback earlier when trying to create example.com, our first DNS Zone was actually still created, so we’ll get an error if we try to use the same name.

After clicking Create we will be taken to the object detail view for the object we just created. Since we didn’t create our own template for the object detail view, we will see the rendered version of the generic html templates for object detail. As you can see, there aren’t any fields displayed from our model-specific fields other than the name. There’s an Advanced tab that shows us some metadata, such as the slug and created and updated timestamps.

The reason this view is so basic is because we did not create an html template, and it is using the default object detail view template provided by Nautobot. We will need to create our own html template to customize the information displayed here.

Creating a Custom Object Retrieve Template for DnsZoneModel

Create a new directory inside nautobot_example_dns_manager called templates. Inside this directory create another directory also called nautobot_example_dns_manager. Nautobot will automatically search for html templates first in templates/<app name> before falling back to the generic templates. Nautobot will then look for a file in this directory called <model_name>_retrieve.html. By convention the model name can be converted to all lowercase and it will still find the file. Let’s create dnszonemodel_retrieve.html.

The complete file path looks like this:

└── nautobot_example_dns_manager
    └── templates
        └── nautobot_example_dns_manager
            └── dnszonemodel_retrieve.html

The good news is we do not have to create all the html for this template from scratch. Using the Django template language we can build off the generic/object_retrieve.html template and then add our own content.

We do this by using the {% extends %} template tag. We also need to load the helpers using {% load %}.

The first two lines should look like this:

{% extends 'generic/object_retrieve.html' %}
{% load helpers %}

The generic object_retrieve template includes a blank template block called content_left_page where we can put our fields. For more complex models with more fields you might use content_right_page and content_full_width_page as well, but for our project adding a table to content_left_page is enough to display all our model fields.

Let’s start by adding a table with just a single data row:

<span role="button" tabindex="0" data-code="{% extends 'generic/object_retrieve.html' %} {% load helpers %} {% block content_left_page %} <div class="panel panel-default"> <div class="panel-heading"> <strong>DNS Zone Model</strong> </div> <table class="table table-hover panel-body attr-table"> <tr> <td>Zone Name Server</td> <td>{{ object.mname|placeholder }}</td> </tr> </table>
{% extends 'generic/object_retrieve.html' %}
{% load helpers %}

{% block content_left_page %}
        <div class="panel panel-default">
            <div class="panel-heading">
                <strong>DNS Zone Model</strong>
            </div>
            <table class="table table-hover panel-body attr-table">
                <tr>
                    <td>Zone Name Server</td>
                    <td>{{ object.mname|placeholder }}</td>
                </tr>
            </table>
        </div>
{% endblock content_left_page %}

Save the file and restart the development server. Once Nautobot has been initialized we can refresh the object detail page and should now see the Zone’s Name Server as an additional field.

Let’s add the rest of the fields from our DNS Zone Model. The finished html template should look like this:

<span role="button" tabindex="0" data-code="{% extends 'generic/object_retrieve.html' %} {% load helpers %} {% block content_left_page %} <div class="panel panel-default"> <div class="panel-heading"> <strong>DNS Zone Model</strong> </div> <table class="table table-hover panel-body attr-table"> <tr> <td>Zone Name Server</td> <td>{{ object.mname|placeholder }}</td> </tr> <tr> <td>Admin Email</td> <td>{{ object.rname|placeholder }}</td> </tr> <tr> <td>Refresh Time (s)</td> <td>{{ object.refresh|placeholder }}</td> </tr> <tr> <td>Retry Time (s)</td> <td>{{ object.retry|placeholder }}</td> </tr> <tr> <td>Expire Time (s)</td> <td>{{ object.expire|placeholder }}</td> </tr> <tr> <td>Time to Live (s)</td> <td>{{ object.ttl|placeholder }}</td> </tr> </table>
{% extends 'generic/object_retrieve.html' %}
{% load helpers %}

{% block content_left_page %}
        <div class="panel panel-default">
            <div class="panel-heading">
                <strong>DNS Zone Model</strong>
            </div>
            <table class="table table-hover panel-body attr-table">
                <tr>
                    <td>Zone Name Server</td>
                    <td>{{ object.mname|placeholder }}</td>
                </tr>
                <tr>
                    <td>Admin Email</td>
                    <td>{{ object.rname|placeholder }}</td>
                </tr>
                <tr>
                    <td>Refresh Time (s)</td>
                    <td>{{ object.refresh|placeholder }}</td>
                </tr>
                <tr>
                    <td>Retry Time (s)</td>
                    <td>{{ object.retry|placeholder }}</td>
                </tr>         
                <tr>
                    <td>Expire Time (s)</td>
                    <td>{{ object.expire|placeholder }}</td>
                </tr>         
                <tr>
                    <td>Time to Live (s)</td>
                    <td>{{ object.ttl|placeholder }}</td>
                </tr>                                                                                                                 
            </table>
        </div>
{% endblock content_left_page %}

Now when we save the file and refresh the page we get this. Much better!

Editing an Existing Object

Editing an existing object is easy because we already created the DnsZoneModelForm class in forms.py. We get the update functionality at the same time as the create functionality. On the object detail view we can click the Edit button on the right side and it will take us to the edit page where we can make our changes and then click Update on the bottom of the page.

Creating a Useful Table View for DnsZoneModel

In part 2 of the blog series we created a very basic table view for the DNS Zone Model. Let’s enhance this table view to be more useful and easier to read.

Open nautobot_example_dns_manager/tables.py. You should see three import lines and a table class for each of our three models:

# nautobot_example_dns_manager/tables.py

"""Nautobot Example DNS Manager Tables."""

import django_tables2 as tables

from nautobot.utilities.tables import BaseTable, ToggleColumn

from .models import DnsZoneModel, ARecordModel, CNameRecordModel


class DnsZoneModelTable(BaseTable):
    class Meta(BaseTable.Meta):
        model = DnsZoneModel


class ARecordModelTable(BaseTable):
    class Meta(BaseTable.Meta):
        model = ARecordModel


class CNameRecordModelTable(BaseTable):
    class Meta(BaseTable.Meta):
        model = CNameRecordModel

In order to display only the columns we care about and in the order we want, we need to add a couple of properties to the nested Meta class. The fields property determines which columns are displayed and their order, and default_columns will control which fields are displayed by default.

Let’s add the fields below to our DnsZoneModelTable class:

# nautobot_example_dns_manager/tables.py
... 
class DnsZoneModelTable(BaseTable):

    class Meta(BaseTable.Meta):
        model = DnsZoneModel
        # fields determines which columns are displayed and their order
        fields = (
            "pk",
            "name",
            "mname",
            "rname",
            "ttl",
            "refresh",
            "retry",
            "expire",
        )
        # default_columns determines which columns are displayed by default
        default_columns = (
            "pk",
            "name",
            "mname",
            "rname",
            "refresh",
            "retry",
            "expire",
            "ttl",
        )

After saving, reloading the development server, and refreshing the page our table should look a little better.

However, we can still improve on this. Our column headers aren’t as descriptive as they could be. Also, wouldn’t it be nice to have selection checkboxes for selecting multiple items? Or how about making the Zone Names into clickable links to take us to the single object detail view?

These improvements can be made by assigning column types to the fields and passing in verbose names. This is done directly in the DnsZoneModelTable class and not the Meta class. We can choose which column types to assign to the fields as well as pass in a human-friendly verbose_name for the columns.

Some common column types are

  • Column: Standard default column
  • ToggleColumn: Replaces the field data with a checkbox for selecting items
  • LinkColumn: Turns field into a link to the object’s detail view
  • EmailColumn: Turns field into email link
# nautobot_example_dns_manager/tables.py

...
class DnsZoneModelTable(BaseTable):
    # if fields aren't defined here they will default to type tables.Column()
    pk = ToggleColumn()
    name = tables.LinkColumn(verbose_name="Zone Name")
    mname = tables.Column(verbose_name="Zone Name Server")
    rname = tables.EmailColumn(verbose_name="Admin Email")
    refresh = tables.Column(verbose_name="Refresh Time (s)")
    retry = tables.Column(verbose_name="Retry Time (s)")
    expire = tables.Column(verbose_name="Expire Time (s)")
    ttl = tables.Column(verbose_name="Time to Live (s)")

    class Meta(BaseTable.Meta):
        model = DnsZoneModel
        ...

After saving, reloading the development server, and refreshing the page our table should look even better and more like what we’re used to seeing in the natively supported Nautobot models.

We can now click on the name of a DNS Zone to take us to the detail page for that object.

There is one last thing we should fix on our table view. There is an error message banner above the table informing us we are missing a view for the import action.

Creating the import function for our view is beyond the scope of this blog post, so we can fix this by defining which action buttons we want on our view and making sure it doesn’t include importBy default, action buttons in views includes addimport, and export. For our views we’ll limit this to just have the add button.

In nautobot_example_dns_manager/views.py add an override for action_buttons to our DnsZoneModelUIViewSet view class:

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
    action_buttons = ("add",) # Limits our buttons above the table to just an add button. This needs to be a tuple and so includes the comma after the quoted string.

After saving and restarting the development server this error banner should now be gone.

Deleting Objects

There are a couple ways we can allow a user to delete an object. Both of these are already available to us based on mixin classes inherited by the view sets. view_mixins.ObjectDestroyViewMixin adds a delete button to the individual object form and view_mixins.ObjectBulkDestroyViewMixin adds a delete button at the bottom of the table view.

In order for the bulk delete button to work, the table needs to have the pk field assigned as a ToggleColumn, which we did earlier. This allows selection of single or multiple objects on the table view.

If you decide you don’t want a bulk delete button on the table view, you can simply remove view_mixins.ObjectBulkDestroyViewMixin from DnsZoneModelUIViewSet. After saving the changes, restarting the development server, and refreshing the table view we should no longer see a delete button under the table.

Forms, Tables, and Edit Views for the Other DNS Manager Models

The process to complete the forms, tables, and edit views for the rest of our DNS Manager models is very similar to what we just covered for the DNS Zone model. The biggest difference is that the ARecord and CNameRecord models contain a field that links them to the DNS Zone they’re part of. This field is called a ForeignKey relationship. In order to make the item in this column a clickable link, we need to use tables.LinkColumn for the column type.

Let’s quickly create forms, an API serializer, and tables for our ARecord model.

ARecord Forms

The form is going to follow the same format used for the DNS Zone model form. Create a new form in nautobot_example_dns_manager/forms.py called ARecordModelForm that inherits from NautobotModelForm. As before, we’ll assign SlugField() to slug, and add a Meta class. The Meta class needs to specify the model as well as the fields. Make sure to import the model at the top of the file.

# nautobot_example_dns_manager/forms.py 

from .models import DnsZoneModel, ARecordModel
# nautobot_example_dns_manager/forms.py

class ARecordModelForm(NautobotModelForm):
    slug = SlugField()

    class Meta:
        model = ARecordModel
        fields = [
            "name",
            "slug",
            "zone",
            "address",
            "ttl",
        ]

Once the form is created, we need to import it into nautobot_example_dns_manager/views.py and then assign it to the form class attribute inside the ARecordModelUIViewSet.

# nautobot_example_dns_manager/views.py 

from .forms import DnsZoneModelForm, ARecordModelForm
# nautobot_example_dns_manager/views.py 

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
    action_buttons = ("add",)

ARecord API Serializer

Once again, because we are using View Sets we need to provide an API serializer for our forms to work. The process again follows the same pattern as creating the API serializer for the DNS Zone model.

API Serializers

Import our ARecordModel:

# nautobot_example_dns_manager/api/serializers.py

from nautobot_example_dns_manager.models import DnsZoneModel, ARecordModel

Add the ARecordModelSerializer class:

# nautobot_example_dns_manager/api/serializers.py

class ARecordModelSerializer(NautobotModelSerializer):
    class Meta:
        model = ARecordModel
        fields = "__all__"

API Views

Import ARecordModel:

# nautobot_example_dns_manager/api/views.py

from nautobot_example_dns_manager.models import DnsZoneModel, ARecordModel

Add the ARecordModelViewSet class:

# nautobot_example_dns_manager/api/views.py

...
class ARecordModelViewSet(NautobotModelViewSet):
    queryset = ARecordModel.objects.all()
    serializer_class = serializers.ARecordModelSerializer

API URLs

And add a new line to register the arecordmodel URLs:

# nautobot_example_dns_manager/api/urls.py

from nautobot.core.api import OrderedDefaultRouter

from . import views

router = OrderedDefaultRouter()
router.register("dnszonemodel", views.DnsZoneModelViewSet)
router.register("arecordmodel", views.ARecordModelViewSet) # Add this to register our arecordmodel URLs

urlpatterns = router.urls

Once the serializer has been created, we need to import it inside nautobot_example_dns_manager/views.py and assign to serializer_class inside ARecordModelUIViewSet:

# nautobot_example_dns_manager/views.py

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 # Add in our serializer to the ViewSet
    action_buttons = ("add",)

ARecord Tables

This process is also very similar to how we set up tables for DnsZoneModel.

Inside nautobot_example_dns_manager/tables.py add the column types to ARecordModelTable and the fields and default_columns to the nested Meta class:

# nautobot_example_dns_manager/tables.py

...
class ARecordModelTable(BaseTable):
    pk = ToggleColumn()
    name = tables.LinkColumn(verbose_name="Name")
    zone = tables.LinkColumn(verbose_name="DNS Zone")
    address = tables.LinkColumn(verbose_name="IP Address")
    ttl = tables.Column(verbose_name="Time To Live (s)")

    class Meta(BaseTable.Meta):
        model = ARecordModel
        # fields determines the ordering of the columns
        fields = ("pk", "name", "zone", "address", "ttl")
        # default_columns determines which columns are displayed
        default_columns = ("pk", "name", "zone", "address", "ttl")
...

We should now be able to add a new A Record object. Note that you will need an IP address created in Nautobot and an available DNS Zone before creating this object.

We can also verify our table view is working as expected. Note that because we used LinkColumns we can click on the DNS Zone or IP address to go to that object’s detail page.

As before, we can also create a custom html template for the A record object detail view.

This process can be repeated to create the forms and tables for our CNameRecordModel. That is beyond the scope of this post but has been completed in the corresponding Part 3 version of nautobot-example-dns-manager for your reference.

References

The following list provides some additional information and resources for Nautobot development:


Conclusion

This blog post has only scratched the surface of forms and tables in Nautobot. A ton of customization and additional features is available in forms and tables to suit your particular use case.

For other code examples of how Nautobot ViewSets, forms, and tables work together, check out Nautobot Circuits Source Code.

For more information on developing Nautobot plugins, see the Official Documentation.

Stay tuned for parts 4 and 5 in this blog series where we will cover Filters, REST API, and GraphQL.

-Paul Jorgenson



ntc img
ntc img

Contact Us to Learn More

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

Author