Writing Your First Nautobot Job, Pt.2

Blog Detail

Welcome to Part 2 of our blog series “Writing Your First Nautobot Job.” The goal of this series is to provide Nautobot users with everything they need to start writing Nautobot Jobs from scratch. Now, we assume you have a basic understanding of the Python programming language and you have Nautobot up and running in your environment.

The first entry in this series (Part 1) reviewed fundamental topics, such as the Django ORM and Django data models. Now that we have a good understanding of how we can access and manipulate the data in Nautobot, we can start exploring the mechanisms within Nautobot that allow us to perform those manipulations in a structured and repeatable way.

Introduction

At the time of writing this blog post, Nautobot has just released a new major version 2.0, which comes with some significant changes to Nautobot Jobs. A full explanation of the changes can be found in our Job Migration Guide. As for this blog post, we will be focusing on Jobs compatible with version 2.0.

Historically, when Network Engineers start down their automation journey they usually begin with one of two technologies, which are either Python scripts or Ansible Playbooks. These technologies are great for tasks that you need to execute frequently. However, when it comes time to expose these tools to other people with limited knowledge or experience, the users can easily become overwhelmed by the unfamiliarity of the mediums where these technologies are executed. Nautobot provides us with a framework to safely provide users with access to our automation scripts inside a UI that is intuitive and familiar. This framework is implemented as the Job class inside of Nautobot.

The Job Class

The Job class is defined in the Nautobot source code here. You will see the Job class is empty but inherits all of its functionality from the parent class BaseJob. This is a common development practice that adds a layer of abstraction and minimizes the impact of changes to the BaseJob class on the Jobs you develop.

When we define our Jobs we will always inherit from the Job class and not the BaseJob class. Here is an example of a Job definition.

# jobs.py
from nautobot.apps.jobs import Job

class ExampleJob(Job):
    """This is our example Job definition.""
    ...

In the example above, we first imported the Job class from the Nautobot source code. Then we defined a class called ExampleJob that inherits from Job.

Settings

Nautobot Jobs have several settings that can modify its behavior. These settings can be defined in the jobs meta class definition or the UI.

To override the defaults, we can set each attribute with an appropriate value under a Meta class definition for our Job.

# jobs.py
from nautobot.apps.jobs import Job

class ExampleJob(Job):
    """This is our example Job definition."""

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."

The full list of the available settings, their default values, and a general description can be found in the table below.

Class AttributeDefault ValueDescription
name(Name of your Job class)The name of the job as it appears in the UI.
description-A general description of what functions the job performs. This can accept either plain text or Markdown-formatted text.
approval_requiredFalseThis boolean dictates whether or not an approval is required before the job can be executed.
dryrun_defaultFalseThis boolean represents the default state of the Dryrun checkbox when a job is run.
has_sensitive_variablesTrueThis boolean has several implications. The first is that it prevents input parameters from being saved to the database. This protects against inadvertent database exposure to sensitive information such as credentials. This setting also enables/disables the ability to rerun jobs (i.e., refill the job input parameters with a click of a button). This will also prevent the job from being scheduled or being marked as requiring approval.
hiddenFalseThis boolean prevents the job from being displayed by default in the UI and requires users to apply specific filters to the Job list view to be seen.
read_onlyFalseThis boolean is just a flag to indicate that the job does not make any changes to the environment. It is up to the author of the job to ensure the actual behavior of the job is “read only”
soft_time_limit300An integer or float value, in seconds, at which the celery.exceptions.SoftTimeLimitExceeded exception will be raised. Jobs can be written to catch this to clean up anything before the hard cutoff time_limit.
time_limit600An integer or float value, in seconds, at which the task is silently terminated.
task_queues[ ]A list of task queue names that the job is allowed to be routed to. By default only the default queue can be used. The queue listed first will be used for a job run via an API call.
template_name-A path relative to the job source code that contains a Django template which provides additional code to customize the Job’s submission form. Example

User Inputs

Now the next functionality of Nautobot Jobs that we need to consider is input variables. We will often want users to provide input data to set the scope of the Job. User inputs are optional, and it can sometimes be better to have no user inputs when the Job performs specific tasks. When we run a job, the first thing that happens is a user input form will be displayed. The types of user input options that are displayed in this form are controlled by the variable types that we use when defining the attribute of our Job class instance.

All job variables support the following default options:

  • default – The field’s default value
  • description – A brief user-friendly description of the field
  • label – The field name to be displayed in the rendered form
  • required – Indicates whether the field is mandatory (all fields are required by default)
  • widget – The class of form widget to use (see the Django documentation)

The full list of input variable types can be found here. However, I will explain some of the nuances of a few of the variable types below.

ChoiceVar and MultiChoiceVar

This input variable type allows you to define a set of choices from which the user can select one; it is rendered as a dropdown menu.

  • choices – A list of (value, label) tuples representing the available choices. For example:
CHOICES = (
    ('n', 'North'),
    ('s', 'South'),
    ('e', 'East'),
    ('w', 'West')
)

direction = ChoiceVar(choices=CHOICES)

In the example above, we first have to define a set of tuples to represent our choices. Then we pass that set of choices as an input parameter of the ChoiceVar. The user will see a dropdown menu with the list of choices being NorthSouthEast, and West. If a user selects North in the dropdown menu, then the direction variable will equal ‘n’.

Similar to ChoiceVar, the MultiChoiceVar allows for the selection of multiple choices and results in a list of values.

ObjectVar and MultiObjectVar

When your user needs to select a particular object within Nautobot, you will need to use an ObjectVar. Each ObjectVar specifies a particular model and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.

  • model – The model class (Device, IPAddress, VLAN, Status, etc.)
  • display_field – The name of the REST API object field to display in the selection list (default: ‘display’)
  • query_params – A dictionary of REST API query parameters to use when retrieving available options (optional)
  • null_option – A label representing a “null” or empty choice (optional)

As you can probably tell, this input type performs an API call to Nautobot itself and constructs the list of options out of the values returned. The display_field argument is useful in cases where using the display API field is not desired for referencing the object. For example, when displaying a list of IP Addresses, you might want to use the dns_name field:

address = ObjectVar(
    model=IPAddress,
    display_field="dns_name",
)

To limit the selections available within the list, additional query parameters can be passed as the query_params dictionary. For example, to show only devices with an “active” status:

device = ObjectVar(
    model=Device,
    query_params={
        'status': 'Active'
    }
)

Multiple values can be specified by assigning a list to the dictionary key. It is also possible to reference the value of other fields in the form by prepending a dollar sign ($) to the variable’s name. The keys you can use in this dictionary are the same ones that are available in the REST API — as an example, it is also possible to filter the Location ObjectVar for its location_type.

location_type = ObjectVar(
    model=LocationType
)
location = ObjectVar(
    model=Location,
    query_params={
        "location_type": "$location_type"
    }
)

Similar to ObjectVar, the MultiObjectVar allows for the selection of multiple objects.

FileVar

An uploaded file. Note that uploaded files are present in memory only for the duration of the job’s execution and they will not be automatically saved for future use. The job is responsible for writing file contents to disk where necessary. This input option is good for when you need to perform bulk operations inside of your job.

IPAddressVar

An IPv4 or IPv6 address, without a mask. Returns a netaddr.IPAddress object. This is rendered as a one-line text box.

10.11.12.13

IPAddressWithMaskVar

An IPv4 or IPv6 address with a mask. Returns a netaddr.IPNetwork object which includes the mask. This is rendered as a one-line text box.

10.11.12.13/24

IPNetworkVar

An IPv4 or IPv6 network with a mask. Returns a netaddr.IPNetwork object. This is rendered as a one-line text box.

Two attributes are available to validate the provided mask:

  • min_prefix_length – Minimum length of the mask
  • max_prefix_length – Maximum length of the mask
10.11.12.0/24

Run()

Now that we have our user inputs defined, we can start defining what actions our job will perform. In our Job class, we define these actions in the run() method. This method takes the self argument and every variable defined on the job as keyword arguments.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        ...

In the example above, we can now reference the variable device in our run method and it will contain the object selected by the user when the job is run.

Job Outputs

The main output of Nautobot Jobs, other than the actions the job performs, is the JobResult. The job results page appears once the job starts running. From this page we can watch as the job executes. The status will be set to “running” and if you have log statements in your job they will be displayed as the job runs. Once the job is finished executing, the status will be updated to either “completed” or “failed”. Additional data, such as who ran the job, what the input variables were set to, and how long the job ran for, are also displayed.

Logging

We can log information from inside our jobs using the logger property of the Job class. This returns a logger object from the standard Python logging module (documentation). You can log messages at different logging levels with the different level methods of the logger. So if we wanted to log a warning message to the users, we would simply add the statement logger.warning("My warning message here.") to our job.

An optional grouping and/or object may be provided in log messages by passing them in the log function call’s extra kwarg. If a grouping is not provided, it will default to the function name that logged the message. The object will default to None.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        logger.warning("This object is not made by Cisco.", extra={"grouping": "validation", "object": device})
        ...

Status Control

As long as a job is completed without raising any exceptions, the job will be marked as “completed” when it finishes running. However, sometimes we want to mark the job as “failed” if certain conditions aren’t met or the result isn’t what we expected. To do this, we have to raise an Exception within our run() method.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        if not device.manufacture.name == "Cisco":
            logger.warning("This object is not made by Cisco.", extra={"grouping": "validation", "object": device})
            raise Exception("A non Cisco device was selected.")

Advanced Features

We have now covered everything you need to write your first Nautobot Job. Most jobs typically perform data manipulation tasks or pull/push data between Nautobot and an external system, but there are some advanced actions that Nautobot Jobs enable us to perform.

  • Job buttons allow us to add a button at the top of a specified object’s ObjectDetailView, which will pass that object as the input parameter for a job and execute it. Read more about Job Buttons here.
  • Job Hooks are jobs that run whenever objects of a specified model are created, updated, or deleted. We define specific tasks to be executed depending on what action was performed against the object. The documentation for Job Hooks can be found here.
  • A job can call another job using the enqueue_job method. We have plans to make this easier in the future.
  • Permissions to run or enable a job can be set per job/user via constraints.
  • Jobs can be approved via API, so an external ticketing system could be configured to read and update approval requests. You can see an example of such an API call here.

Conclusion

In Part 3, we will explore the different ways to load our jobs into Nautobot and the file structure each way requires. Then we will write our first Nautobot Job together.

-Allen



ntc img
ntc img

Contact Us to Learn More

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

Writing Your First Nautobot Job, Pt.1

Blog Detail

Welcome to our blog series “Writing Your First Nautobot Job.” The goal of this series is to provide Nautobot users with everything they need to start writing Nautobot Jobs from scratch. Now, we assume you have a basic understanding of the Python programming language and you have Nautobot up and running in your environment.

The first entry in this series will review the Django ORM and Django data models, which are fundamental to understanding how data is managed in Nautobot. This entry will be followed by a deep dive into the inner workings of the Nautobot Job framework to better our understanding of how Jobs are constructed and executed. The final entry in this series will be a step-by-step guide to writing your first (and useful) Nautobot Job.

Introduction

When starting a new project or doing something for the very first time, taking that first step is always difficult. We typically don’t know how to get started and sometimes we don’t even know where to look to find guidance. The objective of this blog series is to lower the barrier of entry and help guide you through the essentials needed for writing your first Nautobot Job. This initial entry in our series essentially serves as a primer so we can review some foundational topics that need to be understood before we start writing our first Nautobot Job.

What are Nautobot Jobs anyway? Nautobot provides a Job framework that we can use to execute a task or a set of tasks on demand or at a scheduled time. Nautobot Jobs can be used to execute any custom logic with direct access to the Source of Truth data using an object-based permissions framework. All changes can be captured in the change log.

A valid use case might be that your Nautobot Job could take some inputs from the user, manipulate it in some way, and then display some output in the Job logs. The more interesting use cases often involve interacting directly with Nautobot data or even external systems to accomplish various data creation, modification, and validation tasks. However, before we dive into how we do this within our Job, we need to know where we can practice interacting with the Nautobot data.

Nautobot’s Python Shells

Nautobot includes a command-line (CLI) management utility called nautobot-server, which includes various tools to assist with common administrative tasks. Today we’re going to focus only on the two Python shells that are included, but all of the commands are documented in detail in the administration guide. First tool we’re going to take a look at is the Nautobot shell or nbshell. The nbshell is a lightly customized version of the built-in Django shell with all of the relevant Nautobot models preloaded. This means we have a Python environment where we don’t have to import any of the Django models defined in the Nautobot source code and we can access the data in Nautobot directly. We can use this shell to practice interacting with data in Nautobot or test some of the logic in our Job the same way we test the logic of our Python code in the Python shell. Additional documentation about the Nautobot shell can be found here. Thanks to the django-extensions package, we have access to a second and slightly more advanced Python shell called shell-plus. This shell preloads all of the models across all installed plugins as well as the core Nautobot models. The shell also includes the ability to use several different types of interactive Python shells such as IPythonbpython, and ptpython. Additional options and configurations can be found in the official django-extensions documentation.

These Python shells afford users direct access to Nautobot data and function with very little validation in place. As such, it is crucial to ensure that only authorized, knowledgeable users are ever granted access to it. Never perform any action in the management shell without having a full backup in place. You can find a full guide on replicating Nautobot’s database here.

To enter the shell, you need to be sure you are in the same execution environment that Nautobot is running in, and then run one of the following commands:

nautobot-server nbshell

or

nautobot-server shell_plus

Note: Depending on your deployment, you may need to use the nautobot user and activate the virtual environment in the /opt/nautobot directory.

I’m going to be using shell_plus for the examples throughout this blog, so let’s jump into the shell and take a look at it.

user@host % invoke 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 django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from django_celery_beat.models import ClockedSchedule, CrontabSchedule, IntervalSchedule, PeriodicTask, PeriodicTasks, SolarSchedule
from django_rq.models import Queue
from nautobot.circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
from nautobot.dcim.models.cables import Cable, CablePath
from nautobot.dcim.models.device_component_templates import ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate
from nautobot.dcim.models.device_components import ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet, PowerPort, RearPort
from nautobot.dcim.models.devices import Device, DeviceRedundancyGroup, DeviceRole, DeviceType, Manufacturer, Platform, VirtualChassis
from nautobot.dcim.models.locations import Location, LocationType
from nautobot.dcim.models.power import PowerFeed, PowerPanel
from nautobot.dcim.models.racks import Rack, RackGroup, RackReservation, RackRole
from nautobot.dcim.models.sites import Region, Site
from nautobot.extras.models.change_logging import ObjectChange
from nautobot.extras.models.customfields import ComputedField, CustomField, CustomFieldChoice
from nautobot.extras.models.datasources import GitRepository
from nautobot.extras.models.groups import DynamicGroup, DynamicGroupMembership
from nautobot.extras.models.jobs import Job, JobButton, JobHook, JobLogEntry, JobResult, ScheduledJob, ScheduledJobs
from nautobot.extras.models.models import ConfigContext, ConfigContextSchema, CustomLink, ExportTemplate, FileAttachment, FileProxy, GraphQLQuery, HealthCheckTestModel, ImageAttachment, Note, Webhook
from nautobot.extras.models.relationships import Relationship, RelationshipAssociation
from nautobot.extras.models.secrets import Secret, SecretsGroup, SecretsGroupAssociation
from nautobot.extras.models.statuses import Status
from nautobot.extras.models.tags import Tag, TaggedItem
from nautobot.ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from nautobot.tenancy.models import Tenant, TenantGroup
from nautobot.users.models import AdminGroup, ObjectPermission, Token, User
from nautobot.virtualization.models import Cluster, ClusterGroup, ClusterType, VMInterface, VirtualMachine
from social_django.models import Association, Code, Nonce, Partial, UserSocialAuth
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When
from django.utils import timezone
from django.urls import reverse
from django.db.models import Exists, OuterRef, Subquery
Python 3.8.17 (default, Jul 4 2023, 05:29:51) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.34.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]:

As you can see, there’s a lot of activity when we first load into the shell; but there’s a lot of good-to-know information exposed to us. First, we see all of the models from across the application being imported for us so we no longer have to jump into the source code to find their import locations. Next, several common Django utility functions are imported to help us with any advanced tasks we may be doing. Lastly, we have some metadata about the shell itself including what Python version we’re running and what type of interactive shell it is. This output can be very useful later, since we will more than likely have to perform similar imports in our Nautobot Job.

Before we start interacting with the data in Nautobot, we need to understand how that data is being exposed to us and what methods are available for interacting with it.

Django ORM

Nautobot is our Source of Truth (SoT) for information relevant to network automation, so we need to make sure the information in Nautobot stays updated and accurate. The information is stored in the database and users can create/update/delete the information in a variety of ways with the Nautobot GUI and API being the most popular way to do so. A brave soul could also use their SQL skills to perform CRUD operations on the database directly. But luckily, thanks to the Django ORM, we don’t need to learn SQL. Remember Nautobot is an application built on the Django framework, and Django provides us with powerful tools like the ORM to simplify data management.

ORM stands for Object Relational Mapper and the Django ORM provides a Pythonic way to interact with the Models defined in our application and the data stored in our database. This gives us an abstract and programmatic method for accessing, creating, updating, and deleting information without needing to know anything about SQL or even the database itself. The ORM is technically a database abstraction API that translates our Python code into SQL commands that are sent to the database.

Querying Objects

Let’s take a look at how we can retrieve data from Nautobot using the ORM. There are three main components used to construct and execute a query in the ORM.

  • Model Class: Represents the database table being queried.
  • Manager: QuerySet generator (typically “.objects”).
  • Method(s): Functions that represent different SQL operations that are chainable.

In the example code below, DeviceRole is a Model class defined in our Nautobot source code and ultimately represent the table in our database. objects is the Manager of DeviceRole. Both filter() and order_by() are Methods that modify our query. This statement will construct a QuerySet object that executes the SQL operations on our database and returns the results.

DeviceRole.objects.filter(...).order_by(...)

The following table contains some of the most commonly used methods. You can find a complete list in the QuerySet API reference.

NameDescription
all()Default; needed only if no others are added, except delete()
filter()Filter results by matching on one or more attributes
exclude()Inverse of filter()
order_by()Order results by a particular attribute and/or invert direction
get()Get a single object, typically by PK (or raise ObjectNotFound)
first()Get the first matching object (or return None)
last()Get the last matching object (or return None)
exists()Determine whether any matching object exists
count()Return the count of all matching objects

Let’s go through a few examples, starting with a query for all of the Manufacturers.

<span role="button" tabindex="0" data-code="Python 3.8.17 (default, Jul 4 2023, 05:29:51) Type 'copyright', 'credits' or 'license' for more information IPython 7.34.0 — An enhanced Interactive Python. Type '?' for help. In [1]: Manufacturer.objects.all() Out[1]: <RestrictedQuerySet [<Manufacturer: Arista>, <Manufacturer: Cisco>, <Manufacturer: F5>, <Manufacturer: Juniper>,
Python 3.8.17 (default, Jul 4 2023, 05:29:51) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.34.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: Manufacturer.objects.all()
Out[1]: <RestrictedQuerySet [<Manufacturer: Arista>, <Manufacturer: Cisco>, <Manufacturer: F5>, <Manufacturer: Juniper>, <Manufacturer: Palo Alto>]>

As you can see, Manufacturer is our Model class; objects is the model manager that provides an interface for query operations; and all() is a method of our model manager that returns a QuerySet object that contains all of the records in our Manufacturer database table. Now, we’re not going to deep dive into relational databases in this blog series, but in over-simplistic terms, a database table can visually be represented as a basic table in a spreadsheet. Each record in our database table is a row in the table in our spreadsheet. What the Django ORM has done for us is create a Python object for each record in our database table, and that Python object has attributes that map to each column in the database table. Sometimes those columns contain a relationship to one or more records in another database table.

Let’s use first() to return the first Manufacturer object in our QuerySet.

<span role="button" tabindex="0" data-code="In [2]: vendor = Manufacturer.objects.first() In [3]: vendor Out[3]:
In [2]: vendor = Manufacturer.objects.first()

In [3]: vendor
Out[3]: <Manufacturer: Arista>

Database query performance is very important when talking about the performance of an application as a whole, and optimizing our queries can greatly improve the responsiveness of Nautobot. For that purpose, QuerySets are lazy and only specific actions will trigger an actual database query. In our example above, line 2 constructs the QuerySet object and allows us to make further modifications to it. But it is line 3 that triggers the query to be executed on the database. This allows us to construct the perfect QuerySet in multiple statements without wasting valuable IO resources.

Now, let us take a look at a simplified version of our Manufacturer table for reference:

nameslugdescription
AristaaristaArista Networks is an American computer networking company headquartered in Santa Clara, California.
CiscociscoCisco Systems, Inc., commonly known as Cisco, is an American-based multinational digital communications technology conglomerate corporation headquartered in San Jose, California.
F5f5F5, Inc. is an American technology company specializing in application security, multi-cloud management, online fraud prevention, application delivery networking, application availability & performance, network security, and access & authorization.
JuniperjuniperJuniper Networks, Inc. is an American multinational corporation headquartered in Sunnyvale, California.
Palo Altopalo-altoPalo Alto Networks, Inc. is an American multinational cybersecurity company with headquarters in Santa Clara, California.

In our shell, we can access the data in these columns via attributes on our returned Manufacturer object:

In [4]: vendor.name
Out[4]: 'Arista'

In [5]: vendor.slug
Out[5]: 'arista'

In [6]: vendor.description
Out[6]'Arista Networks is an American computer networking company headquartered in Santa Clara, California.'

What if we didn’t have the table of the attributes of a Manufacturer object above? How can we find all the available attributes and relationships of a given Model? Luckily, each model has a Meta class object inside it that can be used to access some useful information about that model. Using the get_fields() method of the related Meta class object, we can display a list of available attributes and relationships along with their types.

<span role="button" tabindex="0" data-code="In [7]: Manufacturer._meta.get_fields() Out[7]: (<ManyToOneRel: dcim.inventoryitem>, <ManyToOneRel: dcim.devicetype>, <ManyToOneRel: dcim.platform>, <django.db.models.fields.UUIDField: id>, <django.db.models.fields.DateField: created>, <django.db.models.fields.DateTimeField: last_updated>, <django.db.models.fields.json.JSONField: _custom_field_data>, <django.db.models.fields.CharField: name>, <nautobot.core.fields.AutoSlugField: slug>, <django.db.models.fields.CharField: description>, <django.contrib.contenttypes.fields.GenericRelation: source_for_associations>,
In [7]: Manufacturer._meta.get_fields()
Out[7]: 
(<ManyToOneRel: dcim.inventoryitem>,
 <ManyToOneRel: dcim.devicetype>,
 <ManyToOneRel: dcim.platform>,
 <django.db.models.fields.UUIDField: id>,
 <django.db.models.fields.DateField: created>,
 <django.db.models.fields.DateTimeField: last_updated>,
 <django.db.models.fields.json.JSONField: _custom_field_data>,
 <django.db.models.fields.CharField: name>,
 <nautobot.core.fields.AutoSlugField: slug>,
 <django.db.models.fields.CharField: description>,
 <django.contrib.contenttypes.fields.GenericRelation: source_for_associations>,
 <django.contrib.contenttypes.fields.GenericRelation: destination_for_associations>)

Unfortunately, this only gives us part of the info we need for reverse relationships. As you can see in the output, the first three items are of type ManytoOneRel which indicates there is a Reverse One-to-Many Relationship between the Manufacturer model and the three models listed. So how do we access the related objects? Well, the Meta comes to the rescue again with the attribute fields_map, which returns a dictionary of the reverse relationship accessors and their types.

<span role="button" tabindex="0" data-code="In [9]: Manufacturer._meta.fields_map Out[9]: {'inventory_items': <ManyToOneRel: dcim.inventoryitem>, 'device_types': <ManyToOneRel: dcim.devicetype>, 'platforms':
In [9]: Manufacturer._meta.fields_map
Out[9]: 
{'inventory_items': <ManyToOneRel: dcim.inventoryitem>,
 'device_types': <ManyToOneRel: dcim.devicetype>,
 'platforms': <ManyToOneRel: dcim.platform>}

Model relationships are an advanced topic and out of scope for this blog series. However, if you would like to learn more about model relationships, check out this section in the Official Django Documentation.

Now let’s use what we’ve learned to query all of the Device Types associated with a manufacturer. There are two ways to do this, and we will explore both of them to show how powerful the Django ORM is. First, continuing with our previous examples, we can use the device_types.all() to return another QuerySet object containing all of the DeviceType objects with a relationship to our Manufacturer object.

<span role="button" tabindex="0" data-code="In [10]: vendor.device_types.all() Out[10]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>,
In [10]: vendor.device_types.all()
Out[10]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>, <DeviceType: vEOS>]>

This is a very complex and powerful tool that is only possible due to the relational database and the Django ORM. We can also obtain the same resulting QuerySet by using filter() on the model manager for the DeviceType model to select all Device Types that are related to our Manufacturer object vendor.

<span role="button" tabindex="0" data-code="In [11]: DeviceType.objects.filter(manufacturer=vendor) Out[11]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>,
In [11]: DeviceType.objects.filter(manufacturer=vendor)
Out[11]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>, <DeviceType: vEOS>]>

In the above example, we are passing in the previously set variable vendor, containing the manufacturer named 'Arista', as a keyword argument called manufacturer. This is equivalent to the following more explicit example:

<span role="button" tabindex="0" data-code="In [12]: DeviceType.objects.filter(manufacturer__name='Arista') Out[12]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>,
In [12]: DeviceType.objects.filter(manufacturer__name='Arista')
Out[12]: <RestrictedQuerySet [<DeviceType: DCS-7150S-24>, <DeviceType: DCS-7280CR2-60>, <DeviceType: vEOS>]>

The double underscore (__) allows us to reference attributes of related models and lets Django connect the dots for us.

Creating Objects

To create a new Manufacturer, we can use its class constructor to instantiate a new Manufacturer object and either pass in the attributes we want to set as keyword arguments. For the Manufacturer model, the only required attribute is name.

In [13]: new_vendor = Manufacturer(name="MyManufacturer")

or

In [13]: new_vendor = Manufacturer()

In [14]: new_vendor.name = "MyManufacturer"

Up to this point, we’ve only created a Manufacturer object in our Python shell and nothing has been saved to our database. We now have to tell the ORM that we’re ready to insert that object in the database. We do this by calling the method validated_save(). This method calls self.full_clean() and self.save() on the object to enforce any model validation rules before saving the object.

Updating Objects

Similarly, if we want to update our new Manufacturer by adding a description, then we follow the same process of setting the attribute and executing validated_save() on the object.

In [15]: new_vendor.description = "My new Manufacturer"

# We need to refresh our Python object to show the updates made to the database by update().
In [16]: new_vendor.validated_save()

In [17]: new_vendor.description
Out[17]: 'My new Manufacturer'

Advanced Methods

Django provides a few advanced methods for scenarios when you’re not entirely sure whether or not something exists.

get_or_create(): This method will query the database for an object that matches the attribute you provide it, or it will create that object if it doesn’t exist. This method will return a tuple of the object and a boolean indicating whether or not it was created. However, it is important to note that this method does not call full_clean() and can bypass the validators defined on the model.

You can find all of the available methods in the QuerySet API reference.

Deleting Objects

Now that we’re done practicing our ORM skills on our test Manufacturer object, let’s delete it.

<span role="button" tabindex="0" data-code="In [18]: new_vendor.delete() In [19]: Manufacturer.objects.filter(name="MyManufacturer") Out[19]:
In [18]: new_vendor.delete()

In [19]: Manufacturer.objects.filter(name="MyManufacturer")
Out[19]: <RestrictedQuerySet []>

Here we are calling the delete() method on the instance itself. We can also call the delete() on a QuerySet. And if we want to be truly destructive, we can chain all() and delete() together to remove all manufacturers.

<span role="button" tabindex="0" data-code="In [20]: Manufacturer.objects.filter(name="MyManufacturer").delete() In [21]: Manufacturer.objects.filter(name="MyManufacturer") Out[21]:
In [20]: Manufacturer.objects.filter(name="MyManufacturer").delete()

In [21]: Manufacturer.objects.filter(name="MyManufacturer")
Out[21]: <RestrictedQuerySet []>

There are no protections when manipulating data directly via the ORM, as all data validation and cleaning are bypassed; so please don’t do these things in a production environment!

What’s Next?

In Part 2, we will explore the Nautobot Job framework and how we can use our Django ORM skills to get the most out of our Jobs!

-Allen



ntc img
ntc img

Contact Us to Learn More

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