Blog Detail
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",
]
Link the DnsZoneModel Form to Our DnsZoneModel View
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:
{% 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:
{% 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 columnToggleColumn
: Replaces the field data with a checkbox for selecting itemsLinkColumn
: Turns field into a link to the object’s detail viewEmailColumn
: 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 import
. By default, action buttons in views includes add
, import
, 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:
- Nautobot Example Plugin – Basic plugin provided with the official Nautobot source
- Nautobot Developer Documentation – Information about Nautobot core and plugin development
- Django Documentation – Nautobot is built on top of Django, so this documentation is great for reference.
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