Nautobot Apps - Lab 04: Adding Views & Templates¶
In the previous lab, we created a custom MaintenanceNotice model, executed a database migration, and extended the Django admin UI. Next, we'll write some custom views and templates to allow users to view maintenance notices within the regular Nautobot UI.
- Nautobot Apps - Lab 04: Adding Views \& Templates
- Task 1: Creating Tables, URL and Views
- Task 2: Set up Testing for our Basic Views
- Task 3: Creating a Detail View
Task 1: Creating Tables, URL and Views¶
With Nautobot, it's incredibly easy to write several different views and quickly get up and running if we take advantage of extending generic view classes. We will create several views, including a view that returns a list of all existing maintenance notices, and map it to a URL. If you didn't create any maintenance notices during the previous lab, go ahead and create some now using the admin UI, to ensure our list view has something to list! In order to take advantage of all the inheritance available to us, we will need to create forms, tables, urls, and views python files that will all work together.
Step 1-0 - Prepare for the Lab¶
For this and all remaining labs, it is advisable to open two terminal windows, one for running the Nautobot development server, and another for executing various commands.
The steps in this lab require that you have a terminal open and have switched to the nautobot user. Verify your username with the whoami command and switch user to nautobot if needed.
ntc@nautobot:~$ whoami
ntc
ntc@nautobot:~$ sudo -iu nautobot
nautobot@nautobot:~$ whoami
nautobot
nautobot@nautobot:~$
If you have the Nautobot development server running from a previous lab, please continue to the next step. Otherwise we need to start the server with the command nautobot-server runserver 0.0.0.0:8080 --insecure.
nautobot@ntc-nautobot-apps:~$ nautobot-server runserver 0.0.0.0:8080 --insecure
Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
April 24, 2022 - 05:19:10
Django version 3.1.14, using settings 'nautobot_config'
Starting development server at http://0.0.0.0:8080/
Quit the server with CONTROL-C.
Step 1-1 - Create the Required Files¶
Create files named tables.py and forms.py in the plugin root (the same path where models.py lives). After, open this file forms.py in your preferred editor.
Change directory to /opt/nautobot/plugin/maintenance_notices and then use the touch tables.py forms.py command to create empty files.
nautobot@nautobot:~$ cd /opt/nautobot/plugin/maintenance_notices
nautobot@nautobot:~/plugin/maintenance_notices$ touch tables.py forms.py
Step 1-2 - Create a Basic Form¶
Lets quickly setup a skeleton form that is necessary for our views to render correctly. We will go into more details on the inner workings of the forms in the next lab. For now we need a basic form available to us to create the proper view classes. Form classes aid in rendering data into HTML forms.
Edit the newly created forms.py file and add the following.
"""Forms for maintenance_notices."""
from django import forms
from nautobot.utilities.forms import (
BootstrapMixin,
)
from maintenance_notices import models
class MaintenanceNoticeForm(BootstrapMixin, forms.ModelForm):
"""MaintenanceNotice creation/edit form."""
class Meta:
"""Meta attributes."""
model = models.MaintenanceNotice
fields = ("__all__")
Our form is very simple and only defines the Model we are interested in and the fields associated with the model inside of the class Meta.
The MaintenanceNoticeForm gets reused by several different view classes. This is helpful to write less code! Again, we will focus on forms in the next lab.
Save the File.
Step 1-3 - Create a Basic Table¶
Open the tables.py file in your editor. We will create a basic table that will be used to render the List view to display all of our maintenance objects. Just like forms that convert data into HTML forms, tables are used to create HTML tables from sets of data. Copy the following code into your tables.py file.
"""Tables for nautobot_plugin_reservation."""
from nautobot.utilities.tables import BaseTable, ButtonsColumn, ToggleColumn
from maintenance_notices import models
class MaintenanceNoticeTable(BaseTable):
"""Table for list view."""
pk = ToggleColumn()
actions = ButtonsColumn(
models.MaintenanceNotice, buttons=("edit", "delete"), pk_field="pk"
)
class Meta(BaseTable.Meta):
"""Meta attributes."""
model = models.MaintenanceNotice
fields = ("pk", "start_time", "end_time", "duration", "comments", "created_by", "devices")
We import BaseTable from nautobot along side ButtonsColumn and ToggleColumn which aid in rendering our tables. The MaintenanceNoticeTable class extends BaseTable.
We define pk as a ToggleColumn, which means that it will have checkbox that allows us to select or unselect that record.
We also want edit and delete buttons in our table, so actions is defined as a ButtonsColumn with the edit and delete buttons. The pk_field is set to pk, as this must be a field that uniquely identifies a record.

The class Meta assigns the model we are using to render our Table and which fields will be displayed and their order of appearance. Notice how we specified fields individually here, instead of using __all__ as we did in our form? This is sometimes the desired behavior, as it allows more control.
Save the File.
Step 1-4 - Create a Basic View¶
Although we could create a fully custom view using Django's View class and write our own get() method, to save time we'll employ Nautobot's Generic views that allow for rapid development while preserving the ability to customize functionality as needed.
Generic views are quite convenient: Simply import the generic module to access the generic ListView and specify a model queryset. Also, we will need to import models and tables
Edit the views.py file.
"""Views for Maintenance Notices."""
from nautobot.core.views import generic
from maintenance_notices import forms, models, tables
class MaintenanceNoticeListView(generic.ObjectListView):
"""List view."""
queryset = models.MaintenanceNotice.objects.all()
table = tables.MaintenanceNoticeTable
action_buttons = ("add",)
Step 1-5 - Create Additional Views¶
We need to add a few more views that will be required for handling CRUD (Create-Read-Update-Delete) operations of MaintenanceNotice objects.
We will need to add the following
- A view for a single object
- A view to Create/Edit an object
- A view to Delete an object
Note: It's sometimes helpful to re-use the Create view in place of an Edit view, if you are not customizing the edit view. This is sub-classing off the same
generic.ObjectEditView. This will become apparent in the URL patterns.
class MaintenanceNoticeView(generic.ObjectView):
"""Detail view."""
queryset = models.MaintenanceNotice.objects.all()
class MaintenanceNoticeCreateView(generic.ObjectEditView):
"""Create view."""
model = models.MaintenanceNotice
queryset = models.MaintenanceNotice.objects.all()
model_form = forms.MaintenanceNoticeForm
class MaintenanceNoticeDeleteView(generic.ObjectDeleteView):
"""Delete view."""
model = models.MaintenanceNotice
queryset = models.MaintenanceNotice.objects.all()
Save the File.
Step 1-6 - Create URL Routes¶
For our view to be accessible from a web browser, it must be mapped to a URL. We don't yet have any URLs defined for our plugin, so edit the file urls.py.
Within this file, we need to define the variable urlpatterns as a list of discrete paths, which are generated by calling Django's path() function. There are three arguments we need to specify for each path in order:
- Route: The string to be appended to the plugin's
base_url - View: The view class (from
views.py), invoked using itsas_view()method - Name: The shorthand name by which we'll reference this URL when using
reverse()(more on this later)
Lets define URL patterns to access our views. If we look closely, we are defining objects by their UUID in the URL patterns. This is possible by the reverse implementation we added to our model in a previous lab. Below we will create a URL for the CRUD operations including the List View to render all available MaintenanceNotice objects in our database.
"""Django urlpatterns declaration for maintenance_notices app."""
from django.urls import path
from maintenance_notices import views
urlpatterns = [
# MaintenanceNotice URLs
path("maintenancenotice/", views.MaintenanceNoticeListView.as_view(), name="maintenancenotice_list"),
path("maintenancenotice/add/", views.MaintenanceNoticeCreateView.as_view(), name="maintenancenotice_add"),
path("maintenancenotice/<uuid:pk>/", views.MaintenanceNoticeView.as_view(), name="maintenancenotice"),
path("maintenancenotice/<uuid:pk>/delete/", views.MaintenanceNoticeDeleteView.as_view(), name="maintenancenotice_delete"),
path("maintenancenotice/<uuid:pk>/edit/", views.MaintenanceNoticeCreateView.as_view(), name="maintenancenotice_edit"),
]
But what exactly is going on with that route? <uuid:pk> is a sort of simplified regular expression to match a set of values within a URL. uuid tells Django to match on any UUID, and set its value as the keyword argument pk when calling the associated view. For example, fetching the URL /plugins/maintenancenotice/50db9f82-19fc-4690-9df2-d18d66d907b2/ will invoke MaintenanceNoticeView with the arguments {'pk': 50db9f82-19fc-4690-9df2-d18d66d907b2}. pk is short for primary key, which is usually (but not always) an alias for the model's ID field.
Save the File.
Step 1-7 - View the URL Patterns¶
Now lets see what all URL patterns are registered for our App.
- From the top Menu bar, access
Plugins->Installed Plugins - Select the
Maintenance Noticeslink - Scroll to the bottom of the page.
You will now see the Views and URLs registered that are accessible from this installed application.

Step 1-8 - Access the List View¶
Lets view our handy work! Remember, only if you have previously created MaintenanceNotice events in the Admin Panel will you get results in this view.
Append /plugins/maintenance_notices/maintenancenotice/ to the URL:8080 of your public nautobot instance

Task 2: Set up Testing for our Basic Views¶
As it is best practice, we will be adding tests to our views just as we did with our models.
Note: We do not need to grant database permissions to the
nautobotuser as we did that in the previous lab.
Step 2-1 - Create a Test Case Class¶
Edit the tests_views.py, which should be in the same directory as the test_models.py. This file will hold our tests related to views.
Let's first import ModelViewTestCase from Nautobot. This class aids in creating tests for views by providing some boilerplate code. Next, import our models file and add the classTestMaintenanceNoticeViews extending the ModelViewTestCase. We need to import our models because we need to tell our new class which model we are dealing with.
from nautobot.utilities.testing import ModelViewTestCase
from maintenance_notices import models
class TestMaintenanceNoticeViews(ModelViewTestCase):
"""Test Maintenance Notice Views."""
model = models.MaintenanceNotice
Step 2-2 - Create a setUpTestData Method¶
Like the test_models.py file, we will be creating a setUpTestData method to prepare the test fixture that will be used for our view tests. We will also be importing datetime and timezone as we need those to create Maintenance Notices.
Note: You may be wondering why we aren't using the
setUpTestDatamethod found intest_models.py. The reason is because thesetUpTestDatamethod only applies to the class it's defined in.
from datetime import datetime, timezone
from nautobot.utilities.testing import ModelViewTestCase
from maintenance_notices import models
class TestMaintenanceNoticeViews(ModelViewTestCase):
"""Test Maintenance Notice Views."""
model = models.MaintenanceNotice
@classmethod
def setUpTestData(cls):
"""Create two Maintenance Notices for view testing."""
maintenance_1 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=30)
maintenance_2 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=60)
Note the additional
datetime import.
Step 2-3 - Test the "list" View¶
Finally, we add a test.
from datetime import datetime, timezone
from django.test import override_settings
from nautobot.utilities.testing import ModelViewTestCase
from maintenance_notices import models
class TestMaintenanceNoticeViews(ModelViewTestCase):
"""Test Maintenance Notice Views."""
model = models.MaintenanceNotice
@classmethod
def setUpTestData(cls):
"""Create two Maintenance Notices for view testing."""
maintenance_1 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=30)
maintenance_2 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=60)
@override_settings(EXEMPT_VIEW_PERMISSIONS=["maintenance_notices.maintenancenotice"])
def test_list_view_anonymous(self):
self.client.logout()
response = self.client.get("/plugins/maintenance_notices/maintenancenotice/")
self.assertHttpStatus(response, 200)
Let's break this test down. The decorator @override_settings is provided by Django and so we must add another line to import it. This decorator allows us to exempt a particular model from view permission enforcement. The next line is the method declaration. We then call the self.client.logout() method. This causes any subsequent calls to come from the Django anonymous user. Lastly, we make a HTTP get request to the url /plugins/maintenance_notices/maintenancenotice/ and assert that we received a 200 response.
Step 2-4 - Improve the Code¶
Recall in our urls.py file that we assigned names to each URLs. We did this with the name= argument.
urlpatterns = [
# MaintenanceNotice URLs
path("maintenancenotice/", views.MaintenanceNoticeListView.as_view(), name="maintenancenotice_list"),
Instead of statically coding the URL in our test function, we can take advantage of the reverse() method and pass it the fully qualified name. By using names instead of explicit paths, we benefit from being able to change the path in a single file (urls.py). As long as the name doesn't change we can still access it. We recommend that you use the path names in your code rather than explicit paths.
First, let's add the from django.urls import reverse statement to our test_views.py file.
...
from django.test import override_settings
from django.urls import reverse
from nautobot.utilities.testing import ModelViewTestCase
...
Next, replace this line:
With these lines:
path = reverse("plugins:maintenance_notices:maintenancenotice_list")
response = self.client.get(path)
Note: We do not provide the complete name here, just the portion after the underscore. We will further explore this shortly.
Step 2-5 - Check Your Work¶
Your completed plugin/maintenance_notices/tests/test_views.py should look like this.
from datetime import datetime, timezone
from django.test import override_settings
from django.urls import reverse
from nautobot.utilities.testing import ModelViewTestCase
from maintenance_notices import models
class TestMaintenanceNoticeViews(ModelViewTestCase):
"""Test Maintenance Notice Views."""
model = models.MaintenanceNotice
@classmethod
def setUpTestData(cls):
"""Create two Maintenance Notices for view testing."""
maintenance_1 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=30)
maintenance_2 = models.MaintenanceNotice.objects.create(start_time=datetime.now(timezone.utc), duration=60)
@override_settings(EXEMPT_VIEW_PERMISSIONS=["maintenance_notices.maintenancenotice"])
def test_list_view_anonymous(self):
self.client.logout()
path = reverse("plugins:maintenance_notices:maintenancenotice_list")
response = self.client.get(path)
self.assertHttpStatus(response, 200)
Step 2-6 - Update ALLOWED_HOSTS in nautobot_config.py¶
When running view tests, the hostname used in request calls is nautobot.example.com. In order for our tests to properly run, we need to update our ALLOWED_HOSTS list found in nautobot_config.py.
Change the ALLOWED_HOSTS from this
...
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "xxx.xxx.xxx.xxx"] # Public IP of your Nautobot instance
...
to
...
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "xxx.xxx.xxx.xxx", "nautobot.example.com"] # Public IP of your Nautobot instance
...
Step 2-7 - Run Tests¶
Once your tests are defined you can run them with nautobot-server test maintenance_notices command.
nautobot@nautobot:~$ nautobot-server test maintenance_notices
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.269s
OK
Destroying test database for alias 'default'...
Note: We have 2 tests because our
test_models.pyfile gets included when we run this command. Also note that tests ran in less than a second, and yet we waited much longer for the tests to complete. This is because most of the time was used to setup the database schemas before the tests actually started.
Step 2-8 - Enter the Debugger¶
Let's take the debugging knowledge we learned in the last lab to inspect what the call to reverse is returning. In our second test lets add our pdb statements.
@override_settings(EXEMPT_VIEW_PERMISSIONS=["maintenance_notices.maintenancenotice"])
def test_list_view_anonymous(self):
self.client.logout()
path = reverse("plugins:maintenance_notices:maintenancenotice_list")
response = self.client.get(path)
import pdb; pdb.set_trace()
self.assertHttpStatus(response, 200)
When we run our tests again we'll get put into the pdb interactive shell.
nautobot@nautobot:~$ nautobot-server test maintenance_notices
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.> /opt/nautobot/plugin/maintenance_notices/tests/test_views.py(26)test_list_view_anonymous()
-> self.assertHttpStatus(response, 200)
(Pdb)
We can then run reverse("plugins:maintenance_notices:maintenancenotice_list") to see what it returns.
(Pdb) reverse("plugins:maintenance_notices:maintenancenotice_list")
'/plugins/maintenance_notices/maintenancenotice/'
(Pdb)
You can see it returned the url for our path named maintenancenotice_list found in our urls.py file. Type c to exit pdb and continue running the test.
(Pdb) c
.
----------------------------------------------------------------------
Ran 2 tests in 3.165s
OK
Destroying test database for alias 'default'...
Step 2-9 - Clean Up¶
As best practice, you should always remove or disable any debugging features in your code before you commit to change control.
Remove the reference to pdb.
Task 3: Creating a Detail View¶
Next, we'll add the corresponding template to provide a detail view; that is, a way to view the details of a specific maintenance notice. This is not necessary for any of the other generic views, such as Create, Delete, and List View.
Step 3-1 - Create the Template File¶
Create a new templates directory. Inside of this directory, we will create a new folder with the name of our application, maintenance_notices.
Note: The
-poption tomkdirtells it to create any folder in the path that do not already exist, so the commandmkdir -p templates/maintenance_noticeswill make both thetemplatesandmaintenance_noticesfolders in a single command.
nautobot@nautobot:~$ cd /opt/nautobot/plugin/maintenance_notices
nautobot@nautobot:~/plugin/maintenance_notices$ mkdir -p templates/maintenance_notices
nautobot@nautobot:~/plugin/maintenance_notices$ touch templates/maintenance_notices/maintenancenotice.html
Step 3-2 - Create an HTML Template¶
This new html file will contain the contents used to render the detail view of a given MaintenanceNotice object in Django. We'll extend Nautobot's base.html template, overriding the title, header, and content blocks. The maintenance notice corresponding to the primary key passed by the URL is available as a template variable named object.
Edit the maintenance_notices/templates/maintenance_notices/maintenancenotice.html file. Start by extending the base template and loading the required components to customize the view.
{% extends 'base.html' %}
{% load buttons %}
{% load static %}
{% load custom_links %}
{% load helpers %}
{% load plugins %}
{% block title %}{{ object }}{% endblock title %}
Step 3-3 - Add the Content Block¶
Add the content block that will render the detail for a specific MaintenanceNotice instance. The template will know this instance by the name object.
{% block content %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>MaintenanceNotice</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Start Time</td>
<td>{{ object.start_time }}</td>
</tr>
<tr>
<td>End Time</td>
<td>{{ object.end_time }}</td>
</tr>
<tr>
<td>Duration</td>
<td>{{ object.duration }} minutes</td>
</tr>
<tr>
<td>Created By</td>
<td>{{ object.created_by | placeholder }}</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ object.comments | placeholder }}</td>
</tr>
</div>
</table>
</div>
</div>
<div class="col-md-6">
<h3>Affected Devices</h3>
<ul>
{% for device in object.devices.all %}
<li><a href="{{ device.get_absolute_url }}">{{ device }}</a></li>
{% endfor %}
</ul>
</div>
{% include 'inc/custom_fields_panel.html' %}
{% include 'inc/relationships_panel.html' %}
{% plugin_left_page object %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock content %}
Save the File.
Step 3-4 - Decipher the Code¶
This step has no action other than to look at the code we just copied.
The top section is building a panel on the web page, which will hold a table. You can see that the table has field names followed by the value, which is obtained using {{ object.field_name }}.
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>MaintenanceNotice</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Start Time</td>
<td>{{ object.start_time }}</td>
</tr>
...
Notice toward the end of the content block that we're looping through each of the associated devices by referencing object.devices.all. Also note that we're embedding a link to each device by calling device.get_absolute_url. This is a standard Django convention for determining the unique URL by which a specific object can be viewed.
{% for device in object.devices.all %}
<li><a href="{{ device.get_absolute_url }}">{{ device }}</a></li>
{% endfor %}
Note: When
object.methodsare called from the template, they do not use the parenthesis that would be required if this were Python code.
Step 3-5 - Access the Reverse URL Lookup¶
Recall that we implemented the get_absolute_url() method in the models.py file in a previous lab:
class MaintenanceNotice(models.Model):
...
class Meta:
...
def save(self, *args, **kwargs):
...
def get_absolute_url(self):
return reverse('plugins:maintenance_notices:maintenancenotice', args=[self.pk])
This employs the Django reverse URL resolution to return a detail view link for the instance. For example, a MaintenanceNotice instance with a pk of 123 will return /plugins/maintenancenotice/123/.
Let's see this method in action. Use the nautobot-server shell_plus command to start a Python shell.
nautobot@ntc-nautobot-apps:~/plugin/maintenance_notices$ nautobot-server shell_plus
# Shell Plus Model Imports
from constance.backends.database.models import Constance
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission
...
from maintenance_notices.models import MaintenanceNotice
...
from django.db.models import Exists, OuterRef, Subquery
Python 3.8.10 (default, Mar 15 2022, 12:22:08)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>>
The shell_plus command starts an interactive Python shell. The plus is that it finds and imports every model for us, including our MaintenanceNotice model. Lets grab the first instance and call the get_absolute_url() method.
Note: The shell supports tab completion, so you can type
Maint<TAB>and it will complete the model name for you.
>>> MaintenanceNotice.objects.first()
<MaintenanceNotice: 2022-03-31 14:00 (75 minutes)>
>>> MaintenanceNotice.objects.first().get_absolute_url()
'/plugins/maintenance_notices/maintenancenotice/114ed199-4bd2-428f-a183-ec0ed6d974a7/'
Step 3-6 - Add Links with Linkify¶
Now that we have a detail view template for our MaintenanceNotice, we should add links to the list view, so we can easily access the detail view pages.
Edit the tables.py file and modify the MaintenanceNoticeTable class to linkify the start time of each object in our list view. This will provide a link to the detail view of each object.
First, import django_tables2.
Inside the MaintenanceNoticeTable class add the line start_time = tables.Column(linkify=True)
class MaintenanceNoticeTable(BaseTable):
"""Table for list view."""
pk = ToggleColumn()
start_time = tables.Column(linkify=True)
actions = ButtonsColumn(
models.MaintenanceNotice, buttons=("edit", "delete"), pk_field="pk"
)
Save the File.
Your completed tables.py file should look like this:
"""Tables for nautobot_plugin_reservation."""
import django_tables2 as tables
from nautobot.utilities.tables import BaseTable, ButtonsColumn, ToggleColumn
from maintenance_notices import models
class MaintenanceNoticeTable(BaseTable):
"""Table for list view."""
pk = ToggleColumn()
start_time = tables.Column(linkify=True)
actions = ButtonsColumn(
models.MaintenanceNotice, buttons=("edit", "delete"), pk_field="pk"
)
class Meta(BaseTable.Meta):
"""Meta attributes."""
model = models.MaintenanceNotice
fields = ("pk", "start_time", "end_time", "duration", "comments", "created_by", "devices")
Step 3-7 - Check Your Work¶
This will link each maintenance notice in the list to its detail view, based on the object. Return to the list view, by appending /plugins/maintenance_notices/maintenancenotice/ to the URL:8080, you will see all the reservation's start_time as a link, which re-direct to the detail view of the object.

Click on one of the links to see the detail view. This is the rendered output of our maintenance_notices/templates/maintenance_notices/maintenancenotice.html file.

Step 3-8 - Commit Your Changes¶
Remember to commit your changes to git:
nautobot@nautobot:~$ cd ~/plugin/maintenance_notices
nautobot@nautobot:~/plugin/maintenance_notices$ git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
modified: urls.py
modified: views.py
Untracked files:
(use "git add <file>..." to include in what will be committed)
forms.py
tables.py
templates/
no changes added to commit (use "git add" and/or "git commit -a")
nautobot@nautobot:~/plugin/maintenance_notices$ git add -A
nautobot@nautobot:~/plugin/maintenance_notices$ git commit -m "Completed Lab 4."
This lab is now complete. Check your work against the solution guide before proceeding with the next lab.