├── README.md ├── images ├── step01-django-admin-plugins.png ├── step05-accesslist-form.png ├── step05-accesslist-list.png ├── step06-accesslist1.png ├── step06-accesslist2.png ├── step06-accesslistrule.png ├── step07-menu-items1.png ├── step07-menu-items2.png ├── step08-filter-form.png ├── step09-rest-api1.png ├── step09-rest-api2.png ├── step10-graphiql.png └── step11-search-results.png └── tutorial ├── step01-initial-setup.md ├── step02-models.md ├── step03-tables.md ├── step04-forms.md ├── step05-views.md ├── step06-templates.md ├── step07-navigation.md ├── step08-filter-sets.md ├── step09-rest-api.md ├── step10-graphql-api.md └── step11-search.md /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Plugin Development Tutorial 2 | 3 | This guide seeks to demonstrate the process of developing a custom plugin for NetBox v3.2 or later. By following each of the prescribed steps, the reader will create from scratch a simple plugin for managing access lists in NetBox, utilizing all major components of the NetBox plugin framework. 4 | 5 | A completed copy of the demo plugin created in this tutorial is available in the [`netbox-plugin-demo`](https://github.com/netbox-community/netbox-plugin-demo) repository for reference. For your convenience, the completed code corresponding to each step in the tutorial exists as a named branch in the demo repo. For example, if you want to start fresh on step 5, simply check out the `step04-forms` branch. 6 | 7 | ### Prerequisites 8 | 9 | Before attempting to create a plugin, please assess your personal ability. Plugin authors should have reasonable proficiency in the following: 10 | 11 | * Python programming 12 | * The [Django](https://www.djangoproject.com/) framework 13 | * REST API fundamentals (where applicable) 14 | * Installing, configuring, and using NetBox 15 | 16 | ### Contents 17 | 18 | * [Step 1: Initial Setup](/tutorial/step01-initial-setup.md) :arrow_left: Start here! 19 | * [Step 2: Models](/tutorial/step02-models.md) 20 | * [Step 3: Tables](/tutorial/step03-tables.md) 21 | * [Step 4: Forms](/tutorial/step04-forms.md) 22 | * [Step 5: Views](/tutorial/step05-views.md) 23 | * [Step 6: Templates](/tutorial/step06-templates.md) 24 | * [Step 7: Navigation](/tutorial/step07-navigation.md) 25 | * [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) 26 | * [Step 9: REST API](/tutorial/step09-rest-api.md) 27 | * [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) 28 | * [Step 11: Search](/tutorial/step11-search.md) 29 | 30 | ### Reference 31 | 32 | * [NetBox Plugin Development Documentation](https://netbox.readthedocs.io/en/stable/plugins/development/) 33 | * [NetBox Labs Certified Plugin Program](https://github.com/netbox-community/netbox/wiki/Plugin-Certification-Program) 34 | 35 | ### Getting Help 36 | 37 | If you run into any snags working through the tutorial, please join us in the **#netbox** channel on the [NetDev Community Slack](https://netdev.chat/) for help. 38 | 39 | ### Feedback and Issues 40 | 41 | If you happen to uncover an error or discrepancy in the tutorial, please be sure to [open an issue](https://github.com/netbox-community/netbox-plugin-tutorial/issues/new/choose) so that it can be documented and fixed. 42 | 43 | -------------------------------------------------------------------------------- /images/step01-django-admin-plugins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step01-django-admin-plugins.png -------------------------------------------------------------------------------- /images/step05-accesslist-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step05-accesslist-form.png -------------------------------------------------------------------------------- /images/step05-accesslist-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step05-accesslist-list.png -------------------------------------------------------------------------------- /images/step06-accesslist1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step06-accesslist1.png -------------------------------------------------------------------------------- /images/step06-accesslist2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step06-accesslist2.png -------------------------------------------------------------------------------- /images/step06-accesslistrule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step06-accesslistrule.png -------------------------------------------------------------------------------- /images/step07-menu-items1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step07-menu-items1.png -------------------------------------------------------------------------------- /images/step07-menu-items2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step07-menu-items2.png -------------------------------------------------------------------------------- /images/step08-filter-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step08-filter-form.png -------------------------------------------------------------------------------- /images/step09-rest-api1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step09-rest-api1.png -------------------------------------------------------------------------------- /images/step09-rest-api2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step09-rest-api2.png -------------------------------------------------------------------------------- /images/step10-graphiql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step10-graphiql.png -------------------------------------------------------------------------------- /images/step11-search-results.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netbox-community/netbox-plugin-tutorial/09babadc3d983d5668a62c3ac176735a4b370f9a/images/step11-search-results.png -------------------------------------------------------------------------------- /tutorial/step01-initial-setup.md: -------------------------------------------------------------------------------- 1 | # Step 1: Initial Setup 2 | 3 | Before we can begin work on our plugin, we must first ensure that we have a suitable development environment in place. 4 | 5 | ## Set Up the Development Environment 6 | 7 | ### Install NetBox 8 | 9 | Plugin development requires a local installation of NetBox. If you don't already have NetBox installed, please consult the [installation instructions](https://netbox.readthedocs.io/en/stable/installation/). 10 | 11 | Be sure to enable debugging in your NetBox configuration by setting `DEBUG = True`. This will ensure that static assets can be served by the development server, and return complete tracebacks whenever there's a server error. 12 | 13 | :green_circle: **Tip:** If this installation will be for development use only, it is generally necessary to complete only up to step three in the installation guide, culminating with successfully running the NetBox development server (`manage.py runserver`). 14 | 15 | :warning: **Warning:** This guide requires NetBox v3.2 or later. Attempting to use an earlier NetBox release will not work. 16 | 17 | ### Clone the git Repository 18 | 19 | Next, we'll clone the demo git repository from GitHub. First, `cd` into your preferred location (your home directory is probably fine), then clone the repo with `git clone`. We're checking out the `init` branch, which will provide us with an empty workspace to start. 20 | 21 | ```bash 22 | $ git clone --branch step00-empty https://github.com/netbox-community/netbox-plugin-demo 23 | Cloning into 'netbox-plugin-demo'... 24 | remote: Enumerating objects: 58, done. 25 | remote: Counting objects: 100% (58/58), done. 26 | remote: Compressing objects: 100% (42/42), done. 27 | remote: Total 58 (delta 12), reused 58 (delta 12), pack-reused 0 28 | Unpacking objects: 100% (58/58), done. 29 | ``` 30 | 31 | :blue_square: **Note:** It isn't strictly required to clone the demo repository, but it will enable you to conveniently check out snapshots of the code as the lessons progress and overcome any hiccups. 32 | 33 | ## Plugin Configuration 34 | 35 | ### Create `__init__.py` 36 | 37 | The `PluginConfig` class holds all the information needs to know about our plugin to install it. First, we'll create a subdirectory to hold our plugin's Python code, as well as an `__init__.py` file to hold the `PluginConfig` definition. 38 | 39 | ```bash 40 | $ mkdir netbox_access_lists 41 | $ touch netbox_access_lists/__init__.py 42 | ``` 43 | 44 | Next, open `__init__.py` in the text editor of your choice and import the `PluginConfig` class from NetBox at the top of the file. 45 | 46 | ```python 47 | from netbox.plugins import PluginConfig 48 | ``` 49 | 50 | ### Create the PluginConfig Class 51 | 52 | We'll create a new class named `NetBoxAccessListsConfig` by subclassing `PluginConfig`. This will define all the necessary parameters that control the configuration of our plugin once installed. There are [many optional attributes](https://netbox.readthedocs.io/en/stable/plugins/development/#pluginconfig-attributes) that can be set here, but for now we only need to define a few. 53 | 54 | ```python 55 | class NetBoxAccessListsConfig(PluginConfig): 56 | name = 'netbox_access_lists' 57 | verbose_name = ' NetBox Access Lists' 58 | description = 'Manage simple ACLs in NetBox' 59 | version = '0.1' 60 | base_url = 'access-lists' 61 | ``` 62 | 63 | This will be sufficient to install our plugin in NetBox later on. Finally, we need to expose this class as `config` to ensure that NetBox detects it. Add this line to the end of the file: 64 | 65 | ```python 66 | config = NetBoxAccessListsConfig 67 | ``` 68 | 69 | ## Create a README 70 | 71 | It's considered best practice to always include a `README` file with any code you publish. This is a brief piece of documentation that explains your project's purpose, how to install/run it, where to find help, etc. Because this is just a learning exercise, we don't have much to say about our plugin, but go ahead and create the file anyway. 72 | 73 | Back in the project's root (one level up from `__init__.py`), create a file named `README.md` and enter the following content: 74 | 75 | ```markdown 76 | ## netbox-access-lists 77 | 78 | Manage simple access control lists in NetBox 79 | ``` 80 | 81 | :green_circle: **Tip:** You'll notice that we've given our `README` file a `md` extension. This tells tools which support it to render the file as Markdown for better readability. 82 | 83 | ## Install the Plugin 84 | 85 | ### Create `setup.py` 86 | 87 | To enable the installation of our plugin into the virtual environment we created above, we'll create a simple Python setup script. In the project's root directory, create a file named `setup.py` and enter the code below. 88 | 89 | ```python 90 | from setuptools import find_packages, setup 91 | 92 | setup( 93 | name='netbox-access-lists', 94 | version='0.1', 95 | description='An example NetBox plugin', 96 | install_requires=[], 97 | packages=find_packages(), 98 | include_package_data=True, 99 | zip_safe=False, 100 | ) 101 | ``` 102 | 103 | :warning: **Warning:** Be sure to create `setup.py` in the project root and _not_ within the `netbox_access_lists` directory. 104 | 105 | This file will call the `setup()` function provided by Python's [`setuptools`](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/) library to install our code. There are plenty of additional arguments that can be passed, but for our example this is sufficient. 106 | 107 | :green_circle: **Tip:** There are alternative methods for installing Python code which work just as well; feel free to use your preferred approach. Just be aware that this guide assumes the use of `setuptools` and adjust accordingly. 108 | 109 | ### Activate the Virtual Environment 110 | 111 | To ensure our plugin is accessible to the NetBox installation, we first need to activate the Python [virtual environment](https://docs.python.org/3/library/venv.html) that was created when we installed NetBox. To do this, determine the virtual environment's path (this will be `/opt/netbox/venv/` if you use the documentation's defaults) and activate it: 112 | 113 | ```bash 114 | $ source /opt/netbox/venv/bin/activate 115 | ``` 116 | 117 | ### Run `setup.py` 118 | 119 | We can now install our plugin by running `setup.py`. First, make sure the virtual environment is still active, then run the following command from the project's root. The `develop` argument tells `setuptools` to create a link to our local development path instead of copying files into the virtual environment. This avoids the need to re-install the plugin every time we make a change. 120 | 121 | ```bash 122 | $ python3 setup.py develop 123 | running develop 124 | running egg_info 125 | creating netbox_access_lists.egg-info 126 | writing manifest file 'netbox_access_lists.egg-info/SOURCES.txt' 127 | writing manifest file 'netbox_access_lists.egg-info/SOURCES.txt' 128 | running build_ext 129 | ``` 130 | 131 | ### Configure NetBox 132 | 133 | Finally, we need to configure NetBox to enable our new plugin. Over in the NetBox installation path, open `netbox/netbox/configuration.py` and look for the `PLUGINS` parameter; this should be an empty list. (If it's not yet defined, go ahead and create it.) Add the name of our plugin to this list: 134 | 135 | ```python 136 | # configuration.py 137 | PLUGINS = [ 138 | 'netbox_access_lists', 139 | ] 140 | ``` 141 | 142 | Save the file and run the NetBox development server (if not already running): 143 | 144 | ```bash 145 | $ python netbox/manage.py runserver 146 | ``` 147 | 148 | You should see the development server start successfully. Open NetBox in a new browser window, log in as a superuser, and navigate to the admin UI. Under **System > Installed Plugins** you should see our plugin listed. 149 | 150 | ![Django admin UI: Plugins list](/images/step01-django-admin-plugins.png) 151 | 152 | :green_circle: **Tip:** You can check your work at the end of each step in the tutorial by running a `git diff` against the corresponding branch. For example, at the end of step one, run `git diff remotes/origin/step01-initial-setup` to compare your work with the completed step. This will help identify any tasks you might have missed. 153 | 154 | This completes our initial setup. Now, onto the fun stuff! 155 | 156 |
157 | 158 | [Step 2: Models](/tutorial/step02-models.md) :arrow_right: 159 | 160 |
161 | 162 | -------------------------------------------------------------------------------- /tutorial/step02-models.md: -------------------------------------------------------------------------------- 1 | # Step 2: Models 2 | 3 | In this step, we're going to define some Django models to hold our plugin's data. A model is a Python class that represents a table in the underlying PostgreSQL database; each instance of a model equates to a row in the table. We use models instead of raw SQL because interacting with Python objects is much more convenient and flexible. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step01-initial-setup`. 6 | 7 | ## Create the Models 8 | 9 | First, `cd` into the `netbox_access_lists` directory and create a file named `models.py`. This is where our model classes will be defined. 10 | 11 | ```bash 12 | $ cd netbox_access_lists 13 | $ edit models.py 14 | ``` 15 | 16 | At the top of the file, import Django's `models` library and NetBox's `NetBoxModel` class. The latter will serve as the base class for our plugin's models. We'll also import the PostgreSQL `ArrayField`; more on this in a bit. 17 | 18 | ```python 19 | from django.contrib.postgres.fields import ArrayField 20 | from django.db import models 21 | from netbox.models import NetBoxModel 22 | ``` 23 | 24 | We'll create two models: 25 | 26 | * `AccessList`: This will represent an access list, with a name and one or more rules assigned to it. 27 | * `AccessListRule`: This will be an individual rule with source/destination IP addresses, port numbers, etc. assigned to an access list. 28 | 29 | ### AccessList 30 | 31 | We'll need to define a few fields for our model. Each model gets a numeric primary key field (`id`) automatically, so we don't need to worry about that, but we do need to define fields for the ACL's name, default action, and optional comments. 32 | 33 | ```python 34 | class AccessList(NetBoxModel): 35 | name = models.CharField( 36 | max_length=100 37 | ) 38 | default_action = models.CharField( 39 | max_length=30 40 | ) 41 | comments = models.TextField( 42 | blank=True 43 | ) 44 | ``` 45 | 46 | By default, model instances are ordered by their primary keys, but it would make more sense to order access lists by name. We can do that by creating a `Meta` child class and defining an `ordering` variable. (Be sure to create the `Meta` class *inside* `AccessList`, not after it.) 47 | 48 | ```python 49 | class Meta: 50 | ordering = ('name',) 51 | ``` 52 | 53 | Finally, we'll add a `__str__()` method to control how an instance is coerced to a string. We'll have this return the value of the instance's `name` field. (Again, be sure to create this method *inside* the `AccessList` class.) 54 | 55 | ```python 56 | def __str__(self): 57 | return self.name 58 | ``` 59 | 60 | ### AccessListRule 61 | 62 | Our second model will hold the individual rules assigned to each access list. This model will be a bit more complex. We'll need to define fields for all of the following: 63 | 64 | * Parent access list (a foreign key to an `AccessList` instance) 65 | * Index (the rule's order in the list) 66 | * Protocol 67 | * Source prefix 68 | * Source port(s) 69 | * Destination prefix 70 | * Destination port(s) 71 | * Action (permit, allow, or reject) 72 | * Description (optional) 73 | 74 | Let's start by defining a `ForeignKey` field pointing to the `AccessList` model. 75 | 76 | ```python 77 | class AccessListRule(NetBoxModel): 78 | access_list = models.ForeignKey( 79 | to=AccessList, 80 | on_delete=models.CASCADE, 81 | related_name='rules' 82 | ) 83 | ``` 84 | 85 | We're passing three keyword arguments to the field: 86 | 87 | * `to` references the related model class 88 | * `on_delete` tells Django what action to take if the related object is deleted. `CASCADE` will automatically delete any rules assigned to a deleted access list. 89 | * `related_name` defines the attribute of the reverse relationship being added to the related class. The rules assigned to an `AccessList` instance can be referenced as `accesslist.rules.all()`. 90 | 91 | Next we'll add an `index` field to store the rule's number (position) within the access list. We'll use `PositiveIntegerField` because only positive numbers are supported. 92 | 93 | ```python 94 | index = models.PositiveIntegerField() 95 | ``` 96 | 97 | The protocol field is next. This will store the name of a protocol such as TCP or UDP. Notice that we're setting `blank=True` because it should not be required to specify a particular protocol when creating a rule. 98 | 99 | ```python 100 | protocol = models.CharField( 101 | max_length=30, 102 | blank=True 103 | ) 104 | ``` 105 | 106 | Next we need to define a source prefix. We're going to use a foreign key field to reference an instance of NetBox's `Prefix` model within its `ipam` app. Instead of importing the model class, we can instead reference it by name. And because we want this to be an _optional_ field, we'll also set `blank=True` and `null=True`. 107 | 108 | ```python 109 | source_prefix = models.ForeignKey( 110 | to='ipam.Prefix', 111 | on_delete=models.PROTECT, 112 | related_name='+', 113 | blank=True, 114 | null=True 115 | ) 116 | ``` 117 | 118 | :green_circle: **Tip:** Whereas `CASCADE` automatically deletes child objects, `PROTECT` prevents the deletion of the parent option if any child objects exist. 119 | 120 | Notice above that we've defined `related_name='+'`. This tells Django not to create a reverse relationship from the `Prefix` model to the `AccessListRule` model, because it wouldn't be very useful. 121 | 122 | We also need to add a field for the source port number(s). We could use an integer field for this, however that would limit us to defining a single source port per rule. Instead, we can add an `ArrayField` to store a list of `PositiveIntegerField` values. Like `source_prefix`, this will also be an optional field, so we add `blank=True` and `null=True` as well. 123 | 124 | ```python 125 | source_ports = ArrayField( 126 | base_field=models.PositiveIntegerField(), 127 | blank=True, 128 | null=True 129 | ) 130 | ``` 131 | 132 | Let's go ahead an add destination prefix and port fields as well. These are essentially duplicates of our source fields. 133 | 134 | ```python 135 | destination_prefix = models.ForeignKey( 136 | to='ipam.Prefix', 137 | on_delete=models.PROTECT, 138 | related_name='+', 139 | blank=True, 140 | null=True 141 | ) 142 | destination_ports = ArrayField( 143 | base_field=models.PositiveIntegerField(), 144 | blank=True, 145 | null=True 146 | ) 147 | ``` 148 | 149 | Finally, we'll add fields for the rule's action and description. The action is required but a description is not. 150 | 151 | ```python 152 | action = models.CharField( 153 | max_length=30 154 | ) 155 | description = models.CharField( 156 | max_length=500, 157 | blank=True 158 | ) 159 | ``` 160 | 161 | With our fields out of the way, this model will also need a `Meta` class to define database ordering and to ensure that every rule has a unique index number within its parent access list. 162 | 163 | ```python 164 | class Meta: 165 | ordering = ('access_list', 'index') 166 | unique_together = ('access_list', 'index') 167 | ``` 168 | 169 | Finally, we'll add a `__str__()` method to display the parent access list and index number when rendering an `AccessListRule` instance as a string: 170 | 171 | ```python 172 | def __str__(self): 173 | return f'{self.access_list}: Rule {self.index}' 174 | ``` 175 | 176 | ## Define Field Choices 177 | 178 | Looking back at our models, we see a few fields that would benefit from having pre-defined choices from which a user can select when creating or modifying an instance. Specifically, we expect a rule's `action` field to only ever have one of three values: 179 | 180 | * Permit 181 | * Deny 182 | * Reject 183 | 184 | We can define a `ChoiceSet` to store these pre-defined values for the user, to avoid the hassle of manually typing the name of the desired action each time. Back at the top of `models.py`, import NetBox's `ChoiceSet` class: 185 | 186 | ```python 187 | from utilities.choices import ChoiceSet 188 | ``` 189 | 190 | Then, below the import statements but above the model definitions, create a child class named `ActionChoices`: 191 | 192 | ```python 193 | class ActionChoices(ChoiceSet): 194 | key = 'AccessListRule.action' 195 | 196 | CHOICES = [ 197 | ('permit', 'Permit', 'green'), 198 | ('deny', 'Deny', 'red'), 199 | ('reject', 'Reject (Reset)', 'orange'), 200 | ] 201 | ``` 202 | 203 | The `CHOICES` attribute must be an iterable of two- or three-value tuples, each of which defines the following: 204 | 205 | * The raw value to be stored in the database 206 | * A human-friendly string for display 207 | * A color for display in the UI (optional, see [available colors](https://docs.netbox.dev/en/stable/configuration/data-validation/#field_choices)) 208 | 209 | Additionally, we've added a `key` attribute: This will allow the NetBox administrator to replace or extend the plugin's default choices via NetBox's [`FIELD_CHOICES`](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#field_choices) configuration parameter. 210 | 211 | Now, we can reference this as the set of valid choices on the `default_action` and `action` model fields by passing it as the `choices` keyword argument. 212 | 213 | ```python 214 | # AccessList 215 | default_action = models.CharField( 216 | max_length=30, 217 | choices=ActionChoices 218 | ) 219 | 220 | # AccessListRule 221 | action = models.CharField( 222 | max_length=30, 223 | choices=ActionChoices 224 | ) 225 | ``` 226 | 227 | Let's create a set of choices for a rule's `protocol` field as well. Add this below the `ActionChoices` class: 228 | 229 | ```python 230 | class ProtocolChoices(ChoiceSet): 231 | 232 | CHOICES = [ 233 | ('tcp', 'TCP', 'blue'), 234 | ('udp', 'UDP', 'orange'), 235 | ('icmp', 'ICMP', 'purple'), 236 | ] 237 | ``` 238 | 239 | Then, add the `choices` keyword argument to the `protocol` field: 240 | 241 | ```python 242 | # AccessListRule 243 | protocol = models.CharField( 244 | max_length=30, 245 | choices=ProtocolChoices, 246 | blank=True 247 | ) 248 | ``` 249 | 250 | ### Add Choice Color Methods 251 | 252 | Now that we've defined choices for some of our model fields, we'll need to provide a method for returning the appropriate color for a selected choice. This works similar to Django's `get_FOO_display()` methods, but returns a color (defined on the field's `ChoiceSet`) rather than a label. This method will be called e.g. when displaying the field in a table. 253 | 254 | Let's add a `get_default_action_color()` method on `AccessList`: 255 | 256 | ```python 257 | class AccessList(NetBoxModel): 258 | # ... 259 | def get_default_action_color(self): 260 | return ActionChoices.colors.get(self.default_action) 261 | ``` 262 | 263 | We also need to add methods for `protocol` and `action` on `AccessListRule`: 264 | 265 | ```python 266 | class AccessListRule(NetBoxModel): 267 | # ... 268 | def get_protocol_color(self): 269 | return ProtocolChoices.colors.get(self.protocol) 270 | 271 | def get_action_color(self): 272 | return ActionChoices.colors.get(self.action) 273 | ``` 274 | 275 | ## Create Schema Migrations 276 | 277 | Now that we have our models defined, we need to generate a schema for the PostgreSQL database. While it's possible to create the tables and constraints by hand, it's _much_ easier to employ Django's [migrations feature](https://docs.djangoproject.com/en/4.0/topics/migrations/). This will inspect our model classes and generate the necessary migration files automatically. This is a two-step process: First we generate the migration file with the `makemigrations` management command, then we run `migrate` to apply it to the live database. 278 | 279 | :warning: **Warning:** Before continuing, check that you've set `DEVELOPER=True` in NetBox's `configuration.py` file. This is necessary to disable a safeguard intended to prevent people from creating new migrations mistakenly. 280 | 281 | ### Generate Migration Files 282 | 283 | Change into the NetBox installation root to run `manage.py`. First, we'll run `makemigrations` with the `--dry-run` argument as a sanity-check. This will report what changes have been detected, but won't actually generate any migration files. 284 | 285 | ```bash 286 | $ python netbox/manage.py makemigrations netbox_access_lists --dry-run 287 | Migrations for 'netbox_access_lists': 288 | ~/netbox-plugin-demo/netbox_access_lists/migrations/0001_initial.py 289 | - Create model AccessList 290 | - Create model AccessListRule 291 | ``` 292 | 293 | We should see a plan to create our plugin's first migration file, `0001_initial.py`, with the two models we defined in `models.py`. (If you encounter an error at this point, or don't see the output above, **stop here** and review your work.) If everything looks good, proceed with creating the migration file (omitting the `--dry-run` argument): 294 | 295 | ```bash 296 | $ python netbox/manage.py makemigrations netbox_access_lists 297 | Migrations for 'netbox_access_lists': 298 | ~/netbox-plugin-demo/netbox_access_lists/migrations/0001_initial.py 299 | - Create model AccessList 300 | - Create model AccessListRule 301 | ``` 302 | 303 | Back in your plugin workspace, you should now see a `migrations` directory with two files: `__init__.py` and `0001_initial.py`. 304 | 305 | ```bash 306 | $ tree 307 | . 308 | ├── __init__.py 309 | ├── migrations 310 | │   ├── 0001_initial.py 311 | │   ├── __init__.py 312 | ... 313 | ``` 314 | 315 | ### Apply Migrations 316 | 317 | Finally, we can apply the migration file using the `migrate` management command: 318 | 319 | ```bash 320 | $ python netbox/manage.py migrate 321 | Operations to perform: 322 | Apply all migrations: admin, auth, circuits, contenttypes, dcim, django_rq, extras, ipam, netbox_access_lists, sessions, social_django, taggit, tenancy, users, virtualization, wireless 323 | Running migrations: 324 | Applying netbox_access_lists.0001_initial... OK 325 | ``` 326 | 327 | If you're curious, you can inspect the newly created database tables, using the `dbshell` management command to enter a PostgreSQL shell: 328 | 329 | ```bash 330 | $ python netbox/manage.py dbshell 331 | psql (10.19 (Ubuntu 10.19-0ubuntu0.18.04.1)) 332 | SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) 333 | Type "help" for help. 334 | 335 | netbox=> \d netbox_access_lists_accesslist 336 | Table "public.netbox_access_lists_accesslist" 337 | Column | Type | Collation | Nullable | Default 338 | -------------------+--------------------------+-----------+----------+------------------------------------------------------------ 339 | id | bigint | | not null | nextval('netbox_access_lists_accesslist_id_seq'::regclass) 340 | created | timestamp with time zone | | | 341 | last_updated | timestamp with time zone | | | 342 | custom_field_data | jsonb | | not null | 343 | name | character varying(100) | | not null | 344 | default_action | character varying(30) | | not null | 345 | comments | text | | not null | 346 | Indexes: 347 | "netbox_access_lists_accesslist_pkey" PRIMARY KEY, btree (id) 348 | Referenced by: 349 | TABLE "netbox_access_lists_accesslistrule" CONSTRAINT "netbox_access_lists__access_list_id_6c1b0317_fk_netbox_ac" FOREIGN KEY (access_list_id) REFERENCES netbox_access_lists_accesslist(id) DEFERRABLE INITIALLY DEFERRED 350 | ``` 351 | 352 | Type `\q` to exit `dbshell`. 353 | 354 | ## Create Some Objects 355 | 356 | Now that we have our models installed, let's try creating some objects. First, enter the NetBox shell. This is an interactive Python command line interface which allows us to interact directly with NetBox objects and other resources. 357 | 358 | ```bash 359 | $ python netbox/manage.py nbshell 360 | from netbox### NetBox interactive shell 361 | ### Python 3.8.12 | Django 4.0.3 | NetBox 3.2.0 362 | ### lsmodels() will show available models. Use help() for more info. 363 | >>> 364 | ``` 365 | 366 | Let's create and save an access list: 367 | 368 | ```python 369 | >>> from netbox_access_lists.models import * 370 | >>> acl = AccessList(name='MyACL1', default_action='deny') 371 | >>> acl 372 | 373 | >>> acl.save() 374 | ``` 375 | 376 | Next we'll create some prefixes to reference in rules: 377 | 378 | ```python 379 | >>> prefix1 = Prefix(prefix='192.168.1.0/24') 380 | >>> prefix1.save() 381 | >>> prefix2 = Prefix(prefix='192.168.2.0/24') 382 | >>> prefix2.save() 383 | ``` 384 | 385 | And finally we'll create a couple rules for our access list: 386 | 387 | ```python 388 | >>> AccessListRule( 389 | ... access_list=acl, 390 | ... index=10, 391 | ... protocol='tcp', 392 | ... destination_prefix=prefix1, 393 | ... destination_ports=[80, 443], 394 | ... action='permit', 395 | ... description='Web traffic' 396 | ... ).save() 397 | >>> AccessListRule( 398 | ... access_list=acl, 399 | ... index=20, 400 | ... protocol='udp', 401 | ... destination_prefix=prefix2, 402 | ... destination_ports=[53], 403 | ... action='permit', 404 | ... description='DNS' 405 | ... ).save() 406 | >>> acl.rules.all() 407 | , ]> 408 | ``` 409 | 410 | Excellent! We can now create access lists and rules in the database. The next few steps will work on exposing this functionality in the NetBox user interface. 411 | 412 |
413 | 414 | :arrow_left: [Step 1: Initial Setup](/tutorial/step01-initial-setup.md) | [Step 3: Tables](/tutorial/step03-tables.md) :arrow_right: 415 | 416 |
417 | 418 | -------------------------------------------------------------------------------- /tutorial/step03-tables.md: -------------------------------------------------------------------------------- 1 | # Step 3: Tables 2 | 3 | You're probably familiar with object lists in NetBox. This is how we display all the instances of a certain type of object, such as sites or devices, in the user interface. These lists are generated by table classes defined for each model, utilizing the [django-tables2](https://django-tables2.readthedocs.io/) library. 4 | 5 | While it would be feasible to generate raw HTML for `` elements directly within the template, this would be cumbersome and difficult to maintain. Additionally, these dynamic table classes provide convenient functionality like sorting and pagination. 6 | 7 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step02-models`. 8 | 9 | ## Create the Tables 10 | 11 | We'll create two tables, one for each of our models. Begin by creating `tables.py` in the `netbox_access_lists/` directory. 12 | 13 | ```bash 14 | $ cd netbox_access_lists/ 15 | $ edit tables.py 16 | ``` 17 | 18 | At the top of this file, import the `django-tables2` library. This will provide the column classes for fields we wish to customize. We'll also import NetBox's `NetBoxTable` class, which will serve as the base class for our tables, and `ChoiceFieldColumn`. Finally we import our plugin's models from `models.py`. 19 | 20 | ```python 21 | import django_tables2 as tables 22 | 23 | from netbox.tables import NetBoxTable, ChoiceFieldColumn 24 | from .models import AccessList, AccessListRule 25 | ``` 26 | 27 | ### AccessListTable 28 | 29 | Create a class named `AccessListTable` as a subclass of `NetBoxTable`. Within this class, create a child `Meta` class inheriting from `NetBoxTable.Meta`; this will define the table's model, fields, and default columns. 30 | 31 | ```python 32 | class AccessListTable(NetBoxTable): 33 | 34 | class Meta(NetBoxTable.Meta): 35 | model = AccessList 36 | fields = ('pk', 'id', 'name', 'default_action', 'comments', 'actions') 37 | default_columns = ('name', 'default_action') 38 | ``` 39 | 40 | The `model` attribute tells `django-tables2` which model to use when building the table, and the `fields` attribute dictates which model fields get added to the table. `default_columns` controls which of the available columns are displayed by default. 41 | 42 | The `pk` and `actions` columns render the checkbox selectors and dropdown menus, respectively, for each table row; these are provided by the NetBoxTable class. The `id` column will display the object's numeric primary key, which is included on almost every table in NetBox but generally disabled by default. The other three columns derive from the fields we defined on the `AccessList` model. 43 | 44 | What we have so far is sufficient to render a table, but we can make some small improvements. First, let's make the `name` column a link to each object. To do this, we'll override the default column by defining `name` on the class and passing `linkify=True`. 45 | 46 | ```python 47 | class AccessListTable(NetBoxTable): 48 | name = tables.Column( 49 | linkify=True 50 | ) 51 | ``` 52 | 53 | Also, recall that the `default_action` field on the `AccessList` model is a choice field, with a color assigned to each choice. To display these values, we'll use NetBox's `ChoiceFieldColumn` class. 54 | 55 | ```python 56 | default_action = ChoiceFieldColumn() 57 | ``` 58 | 59 | It would also be nice to include a count showing the number of rules each access list has assigned to it. We can add a custom column named `rule_count` to show this. (The data for this column will be annotated by the view; more on this in step five.) We'll also need to add this column to our `fields` and (optionally) `default_columns` under the `Meta` subclass. Our finished table should look like this: 60 | 61 | ```python 62 | class AccessListTable(NetBoxTable): 63 | name = tables.Column( 64 | linkify=True 65 | ) 66 | default_action = ChoiceFieldColumn() 67 | rule_count = tables.Column() 68 | 69 | class Meta(NetBoxTable.Meta): 70 | model = AccessList 71 | fields = ('pk', 'id', 'name', 'rule_count', 'default_action', 'comments', 'actions') 72 | default_columns = ('name', 'rule_count', 'default_action') 73 | ``` 74 | 75 | ### AccessListRuleTable 76 | 77 | We'll also create a table for our `AccessListRule` model using the same approach as above. Start by linkifying the `access_list` and `index` columns. The former will link to the parent access list, and the latter will link to the individual rule. We also want to declare `protocol` and `action` as `ChoiceFieldColumn` instances. 78 | 79 | ```python 80 | class AccessListRuleTable(NetBoxTable): 81 | access_list = tables.Column( 82 | linkify=True 83 | ) 84 | index = tables.Column( 85 | linkify=True 86 | ) 87 | protocol = ChoiceFieldColumn() 88 | action = ChoiceFieldColumn() 89 | 90 | class Meta(NetBoxTable.Meta): 91 | model = AccessListRule 92 | fields = ( 93 | 'pk', 'id', 'access_list', 'index', 'source_prefix', 'source_ports', 'destination_prefix', 94 | 'destination_ports', 'protocol', 'action', 'description', 'actions', 95 | ) 96 | default_columns = ( 97 | 'access_list', 'index', 'source_prefix', 'source_ports', 'destination_prefix', 98 | 'destination_ports', 'protocol', 'action', 'actions', 99 | ) 100 | ``` 101 | 102 | This should be all we need to list these objects in the UI. Next, we'll define some forms to enable creating and modifying objects. 103 | 104 |
105 | 106 | :arrow_left: [Step 2: Models](/tutorial/step02-models.md) | [Step 4: Forms](/tutorial/step04-forms.md) :arrow_right: 107 | 108 |
109 | 110 | -------------------------------------------------------------------------------- /tutorial/step04-forms.md: -------------------------------------------------------------------------------- 1 | # Step 4: Forms 2 | 3 | Form classes generate HTML form elements for the user interface, and process and validate user input. They are used in NetBox primarily to create, modify, and delete objects. We'll create a form class for each of our plugin's models. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step03-tables`. 6 | 7 | ## Create the Forms 8 | 9 | Begin by creating a file named `forms.py` in the `netbox_access_lists/` directory. 10 | 11 | ```bash 12 | $ cd netbox_access_lists/ 13 | $ edit forms.py 14 | ``` 15 | 16 | At the top of the file, we'll import NetBox's `NetBoxModelForm` class, which will serve as the base class for our forms. We'll also import our plugin's models. 17 | 18 | ```python 19 | from netbox.forms import NetBoxModelForm 20 | from .models import AccessList, AccessListRule 21 | ``` 22 | 23 | ### AccessListForm 24 | 25 | Create a class named `AccessListForm`, subclassing `NetBoxModelForm`. Under this class, define a `Meta` subclass defining the form's `model` and `fields`. Notice that the `fields` list also includes `tags`: Tag assignment is handled by `NetBoxModel` automatically, so we didn't need to add it to our model in step two. 26 | 27 | ```python 28 | class AccessListForm(NetBoxModelForm): 29 | 30 | class Meta: 31 | model = AccessList 32 | fields = ('name', 'default_action', 'comments', 'tags') 33 | ``` 34 | 35 | This alone is sufficient for our first model, but we can make one tweak: Instead of the default field that Django will generate for the `comments` model field, we can use NetBox's purpose-built `CommentField` class. (This handles some largely cosmetic details like setting a `help_text` and adjusting the field's layout.) To do this, simply import the `CommentField` class and override the form field: 36 | 37 | ```python 38 | from utilities.forms.fields import CommentField 39 | # ... 40 | class AccessListForm(NetBoxModelForm): 41 | comments = CommentField() 42 | 43 | class Meta: 44 | model = AccessList 45 | fields = ('name', 'default_action', 'comments', 'tags') 46 | ``` 47 | 48 | ### AccessListRuleForm 49 | 50 | We'll create a form for `AccessListRule` following the same pattern. 51 | 52 | ```python 53 | class AccessListRuleForm(NetBoxModelForm): 54 | 55 | class Meta: 56 | model = AccessListRule 57 | fields = ( 58 | 'access_list', 'index', 'description', 'source_prefix', 'source_ports', 'destination_prefix', 59 | 'destination_ports', 'protocol', 'action', 'tags', 60 | ) 61 | ``` 62 | 63 | By default, Django will create a "static" foreign key field for related objects. This renders as a dropdown list that's pre-populated with _all_ available objects. As you can imagine, in a NetBox instance with many thousands of objects this can get rather unwieldy. 64 | 65 | To avoid this, NetBox provides the `DynamicModelChoiceField` class. This renders foreign key fields using a special dynamic widget backed by NetBox's REST API. This avoids the overhead imposed by the static field, and allows the user to conveniently search for the desired object. 66 | 67 | :green_circle: **Tip:** The `DynamicModelMultipleChoiceField` class is also available for many-to-many fields, which support the assignment of multiple objects. 68 | 69 | We'll use `DynamicModelChoiceField` for the three foreign key fields in our form: `access_list`, `source_prefix`, and `destination_prefix`. First, we must import the field class, as well as the models of the related objects. `AccessList` is already imported, so we just need to import `Prefix` from NetBox's `ipam` app. The beginning of `forms.py` should now look like this: 70 | 71 | ```python 72 | from ipam.models import Prefix 73 | from netbox.forms import NetBoxModelForm 74 | from utilities.forms.fields import CommentField, DynamicModelChoiceField 75 | from .models import AccessList, AccessListRule 76 | ``` 77 | 78 | Then, we override the three relevant fields on the form class, instantiating `DynamicModelChoiceField` with the appropriate `queryset` value for each. (Be sure to keep in place the `Meta` class we already defined.) 79 | 80 | ```python 81 | class AccessListRuleForm(NetBoxModelForm): 82 | access_list = DynamicModelChoiceField( 83 | queryset=AccessList.objects.all() 84 | ) 85 | source_prefix = DynamicModelChoiceField( 86 | queryset=Prefix.objects.all() 87 | ) 88 | destination_prefix = DynamicModelChoiceField( 89 | queryset=Prefix.objects.all() 90 | ) 91 | ``` 92 | 93 | With our models, tables, and forms all in place, next we'll create some views to bring everything together! 94 | 95 |
96 | 97 | :arrow_left: [Step 3: Tables](/tutorial/step03-tables.md) | [Step 5: Views](/tutorial/step05-views.md) :arrow_right: 98 | 99 |
100 | 101 | -------------------------------------------------------------------------------- /tutorial/step05-views.md: -------------------------------------------------------------------------------- 1 | # Step 5: Views 2 | 3 | Views are responsible for the business logic of your application. Generally, this means processing incoming requests, performing some action(s), and returning a response to the client. Each view typically has a URL associated with it, and can handle one or more types of HTTP requests (i.e. `GET` and/or `POST` requests). 4 | 5 | Django provides a set of [generic view classes](https://docs.djangoproject.com/en/4.0/topics/class-based-views/generic-display/) which handle much of the boilerplate code needed to process requests. NetBox likewise provides a set of view classes to simplify the creation of views for creating, editing, deleting, and viewing objects. They also introduce support for NetBox-specific features such as custom fields and change logging. 6 | 7 | In this step, we'll create a set of views for each of our plugin's models. 8 | 9 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step04-forms`. 10 | 11 | ## Create the Views 12 | 13 | Begin by creating `views.py` in the `netbox_access_lists/` directory. 14 | 15 | ```bash 16 | $ cd netbox_access_lists/ 17 | $ edit views.py 18 | ``` 19 | 20 | We'll need to import our plugin's `models`, `tables`, and `forms` modules: This is where everything we've built so far really comes together! We also need to import NetBox's generic views module, as it provides the base classes for our views. 21 | 22 | ```python 23 | from netbox.views import generic 24 | from . import forms, models, tables 25 | ``` 26 | 27 | :green_circle: **Tip:** You'll notice that we're importing the entire model, form, and tables modules here. If you would prefer to import each of the relevant classes directly, you're certainly welcome to do so; just remember to change the class definitions below accordingly. 28 | 29 | For each model, we need to create four views: 30 | 31 | * **Detail view** - Display a single object 32 | * **List view** - Displays a table of all existing instances of a particular model 33 | * **Edit view** - Handles adding and modifying objects 34 | * **Delete view** - Handles the deletion of an object 35 | 36 | ### AccessList Views 37 | 38 | The general pattern we'll follow here is to subclass a generic view class provided by NetBox, and define the necessary attributes. We won't need to write any substantial code because the views NetBox provides takes care of the request logic for us. 39 | 40 | Let's start with a detail view. We subclass `generic.ObjectView` and define the queryset of objects we want to display. 41 | 42 | ```python 43 | class AccessListView(generic.ObjectView): 44 | queryset = models.AccessList.objects.all() 45 | ``` 46 | 47 | :green_circle: **Tip:** The views require us to define a queryset rather than just a model, because it's sometimes necessary to modify the queryset, e.g. to prefetch related objects or limit by a particular attribute. 48 | 49 | Next, we'll add a list view. For this view, we need to define both `queryset` and `table`. 50 | 51 | ```python 52 | class AccessListListView(generic.ObjectListView): 53 | queryset = models.AccessList.objects.all() 54 | table = tables.AccessListTable 55 | ``` 56 | 57 | :green_circle: **Tip:** It occurs to the author that having chosen a model name that ends with "List" might be a bit confusing here. Just remember that `AccessListView` is the _detail_ (single object) view, and `AccessListListView` is the _list_ (multiple objects) view. 58 | 59 | Before we move on to the next view, do you remember the extra column we added to `AccessListTable` in step three? That column expects to find a count of rules assigned for each access list in the queryset, named `rule_count`. Let's add this to our queryset now. We can employ Django's `Count()` function to extend the SQL query and annotate the count of associated rules. (Don't forget to add the import statement up top.) 60 | 61 | ```python 62 | from django.db.models import Count 63 | # ... 64 | class AccessListListView(generic.ObjectListView): 65 | queryset = models.AccessList.objects.annotate( 66 | rule_count=Count('rules') 67 | ) 68 | table = tables.AccessListTable 69 | ``` 70 | 71 | We'll finish up with the edit and delete views for `AccessList`. Note that for the edit view, we also need to define `form` as the form class we created in step four. 72 | 73 | ```python 74 | class AccessListEditView(generic.ObjectEditView): 75 | queryset = models.AccessList.objects.all() 76 | form = forms.AccessListForm 77 | 78 | class AccessListDeleteView(generic.ObjectDeleteView): 79 | queryset = models.AccessList.objects.all() 80 | ``` 81 | 82 | That's it for the first model! We'll create another four views for `AccessListRule` as well. 83 | 84 | ### AccessListRule Views 85 | 86 | The rest of our views follow the same pattern as the first four. 87 | 88 | ```python 89 | class AccessListRuleView(generic.ObjectView): 90 | queryset = models.AccessListRule.objects.all() 91 | 92 | 93 | class AccessListRuleListView(generic.ObjectListView): 94 | queryset = models.AccessListRule.objects.all() 95 | table = tables.AccessListRuleTable 96 | 97 | 98 | class AccessListRuleEditView(generic.ObjectEditView): 99 | queryset = models.AccessListRule.objects.all() 100 | form = forms.AccessListRuleForm 101 | 102 | 103 | class AccessListRuleDeleteView(generic.ObjectDeleteView): 104 | queryset = models.AccessListRule.objects.all() 105 | ``` 106 | 107 | With our views in place, we now need to make them accessible by associating each with a URL. 108 | 109 | ## Map Views to URLs 110 | 111 | In the `netbox_access_lists/` directory, create `urls.py`. This will define our view URLs. 112 | 113 | ```bash 114 | $ edit urls.py 115 | ``` 116 | 117 | URL mapping for NetBox plugins is pretty much identical to regular Django apps: We'll define `urlpatterns` as an iterable of `path()` calls, mapping URL fragments to view classes. 118 | 119 | First we'll need to import Django's `path` function from its `urls` module, as well as our plugin's `models` and `views` modules. 120 | 121 | ```python 122 | from django.urls import path 123 | from . import models, views 124 | ``` 125 | 126 | We have four views per model, but we actually need to define five paths for each. This is because the add and edit operations are handled by the same view, but accessed via different URLs. Along with the URL and view for each path, we'll also specify a `name`; this allows us to easily reference a URL in code. 127 | 128 | ```python 129 | urlpatterns = ( 130 | path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), 131 | path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), 132 | path('access-lists//', views.AccessListView.as_view(), name='accesslist'), 133 | path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), 134 | path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), 135 | ) 136 | ``` 137 | 138 | We've chosen `access-lists` as the base URL for our `AccessList` model, but you are free to choose something different. However, it is recommended to retain the naming scheme shown, as several NetBox features rely on it. Also note that each of the views must be invoked by its `as_view()` method when passed to `path()`. 139 | 140 | :green_circle: **Tip:** The `` string you see in some of the URLs is a [path converter](https://docs.djangoproject.com/en/stable/topics/http/urls/#path-converters). Specifically, this is an integer (`int`) variable named `pk`. This value is extracted from the request URL and passed to the view when the request is processed, so that the specified object can be located in the database. 141 | 142 | Let's add the rest of the paths now. You may find it helpful to separate the paths by model to make the file more readable. 143 | 144 | ```python 145 | urlpatterns = ( 146 | 147 | # Access lists 148 | path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), 149 | path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), 150 | path('access-lists//', views.AccessListView.as_view(), name='accesslist'), 151 | path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), 152 | path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), 153 | 154 | # Access list rules 155 | path('rules/', views.AccessListRuleListView.as_view(), name='accesslistrule_list'), 156 | path('rules/add/', views.AccessListRuleEditView.as_view(), name='accesslistrule_add'), 157 | path('rules//', views.AccessListRuleView.as_view(), name='accesslistrule'), 158 | path('rules//edit/', views.AccessListRuleEditView.as_view(), name='accesslistrule_edit'), 159 | path('rules//delete/', views.AccessListRuleDeleteView.as_view(), name='accesslistrule_delete'), 160 | 161 | ) 162 | ``` 163 | 164 | ### Adding Changelog Views 165 | 166 | You may recall that one of the features provided by NetBox is automatic [change logging](https://netbox.readthedocs.io/en/stable/additional-features/change-logging/). You can see this in action when viewing a NetBox object and selecting its "Changelog" tab. Since our models inherit from `NetBoxModel`, they too can utilize this feature. 167 | 168 | We'll add a dedicated changelog URL for each of our models. First, back at the top of `urls.py`, we need to import NetBox's `ObjectChangeLogView`: 169 | 170 | ```python 171 | from netbox.views.generic import ObjectChangeLogView 172 | ``` 173 | 174 | Then, we'll add an extra path for each model inside `urlpatterns`: 175 | 176 | ```python 177 | urlpatterns = ( 178 | 179 | # Access lists 180 | # ... 181 | path('access-lists//changelog/', ObjectChangeLogView.as_view(), name='accesslist_changelog', kwargs={ 182 | 'model': models.AccessList 183 | }), 184 | 185 | # Access list rules 186 | # ... 187 | path('rules//changelog/', ObjectChangeLogView.as_view(), name='accesslistrule_changelog', kwargs={ 188 | 'model': models.AccessListRule 189 | }), 190 | 191 | ) 192 | ``` 193 | 194 | Notice that we're using `ObjectChangeLogView` directly here; we did not need to create model-specific subclasses for it. Additionally, we're passing a keyword argument `model` to the view: This specifies the model to be used (which is why we didn't need to subclass the view). 195 | 196 | ## Add Model URL Methods 197 | 198 | Now that we have our URL paths in place, we can add a `get_absolute_url()` method to each of our models. The method is a [Django convention](https://docs.djangoproject.com/en/stable/ref/models/instances/#get-absolute-url); although not strictly required, it conveniently returns the absolute URL for any particular object. For example, calling `accesslist.get_absolute_url()` would return `/plugins/access-lists/access-lists/123/` (where 123 is the primary key of the object). 199 | 200 | Back in `models.py`, import Django's `reverse` function from its `urls` module at the top of the file: 201 | 202 | ```python 203 | from django.urls import reverse 204 | ``` 205 | 206 | Then, add the `get_absolute_url()` method to the `AccessList` class after its `__str__()` method: 207 | 208 | ```python 209 | class AccessList(NetBoxModel): 210 | # ... 211 | def get_absolute_url(self): 212 | return reverse('plugins:netbox_access_lists:accesslist', args=[self.pk]) 213 | ``` 214 | 215 | `reverse()` takes two arguments here: The view name, and a list of positional arguments. The view name is formed by concatenating three components: 216 | 217 | * The string `'plugins'` 218 | * The name of our plugin 219 | * The name of the desired URL path (defined as `name='accesslist'` in `urls.py`) 220 | 221 | The object's `pk` attribute is passed as well, and replaces the `` path converter in the URL. 222 | 223 | We'll add a `get_absolute_url()` method for `AccessListRule` as well, adjusting the view name accordingly. 224 | 225 | ```python 226 | class AccessListRule(NetBoxModel): 227 | # ... 228 | def get_absolute_url(self): 229 | return reverse('plugins:netbox_access_lists:accesslistrule', args=[self.pk]) 230 | ``` 231 | 232 | ## Test the Views 233 | 234 | Now for the moment of truth: Has all our work thus far yielded functional UI views? Check that the development server is running, then open a browser and navigate to . You should see the access list list view and (if you followed in step two) a single access list named MyACL1. 235 | 236 | :blue_square: **Note:** This guide assumes that you're running the Django development server locally on port 8000. If your setup is different, you'll need to adjust the link above accordingly. 237 | 238 | ![Access lists list view](/images/step05-accesslist-list.png) 239 | 240 | We see that our table has successfully render the `name`, `rule_count`, and `default_action` columns that we defined in step three, and the `rule_count` column shows two rules assigned as expected. 241 | 242 | If we click the "Add" button at top right, we'll be taken to the access list creation form. (Creating a new access list won'r work yet, but the form should render as seen below.) 243 | 244 | ![Access list creation form](/images/step05-accesslist-form.png) 245 | 246 | However, if you click a link to an access list in the table, you'll be met by a `TemplateDoesNotExist` exception. This means exactly what it says: We have not yet defined a template for this view. Don't worry, that's coming up next! 247 | 248 | :blue_square: **Note:** You might notice that the "add" view for rules still doesn't work, raising a `NoReverseMatch` exception. This is because we haven't yet defined the REST API backends required to support the dynamic form fields. We'll take care of this when we build out the REST API functionality in step nine. 249 | 250 |
251 | 252 | :arrow_left: [Step 4: Forms](/tutorial/step04-forms.md) | [Step 6: Templates](/tutorial/step06-templates.md) :arrow_right: 253 | 254 |
255 | 256 | -------------------------------------------------------------------------------- /tutorial/step06-templates.md: -------------------------------------------------------------------------------- 1 | # Step 6: Templates 2 | 3 | Templates are responsible for rendering HTML content for NetBox views. Each template exists as a file with a mix of HTML and template code. Generally speaking, each model in a NetBox plugin must have its own template. Templates may also be created or customized for other views, but the default templates NetBox provides are suitable in most cases. 4 | 5 | NetBox's rendering backend uses the [Django Template Language](https://docs.djangoproject.com/en/stable/topics/templates/) (DTL). It will immediately look very familiar if you've used [Jinja2](https://jinja2docs.readthedocs.io/en/stable/), but be aware that there are some important differences between the two. Generally, DTL is much more limited in the types of logic it can execute: Directly executing Python code, for instance, is not possible. Be sure to study the Django documentation before attempting to create any complex templates. 6 | 7 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step05-views`. 8 | 9 | ## Template File Structure 10 | 11 | NetBox looks for templates within the `templates/` directory (if it exists) within the plugin root. Within this directory, create a subdirectory bearing the name of the plugin: 12 | 13 | ```bash 14 | $ cd netbox_access_lists/ 15 | $ mkdir -p templates/netbox_access_lists/ 16 | ``` 17 | 18 | The template files will reside in this directory. Default templates are provided for all generic views except for `ObjectView`, so we'll need to create templates for our `AccessListView` and `AccessListRuleView` views. 19 | 20 | By default, each `ObjectView` subclass will look for a template bearing the name of its associated model. For instance, `AccessListView` will look for `accesslist.html`. This can be overriden by setting `template_name` on the view, but this behavior is suitable for our purposes. 21 | 22 | ## Create the AccessList Template 23 | 24 | Begin by creating the file `accesslist.html` in the plugin's template directory. 25 | 26 | ```bash 27 | $ edit templates/netbox_access_lists/accesslist.html 28 | ``` 29 | 30 | Although we need to create our own template, NetBox has done much of the work for us, and provides a generic template that we can easily extend. At the top of the file, add an `extends` tag: 31 | 32 | ``` 33 | {% extends 'generic/object.html' %} 34 | ``` 35 | 36 | This tells the rendering engine to first load the NetBox template at `generic/object.html` and populate only the content we provide within `block` tags. 37 | 38 | Let's extend the generic template's `content` block with some information about the access list. 39 | 40 | ``` 41 | {% block content %} 42 |
43 |
44 |
45 |
Access List
46 |
47 |
48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
Name{{ object.name }}
Default Action{{ object.get_default_action_display }}
Rules{{ object.rules.count }}
61 | 62 | 63 | {% include 'inc/panels/custom_fields.html' %} 64 | 65 |
66 | {% include 'inc/panels/tags.html' %} 67 | {% include 'inc/panels/comments.html' %} 68 |
69 | 70 | {% endblock content %} 71 | ``` 72 | 73 | Here we've created a Boostrap 5 row and two column elements. In the first column, we have a simple card to display the access list's name and default action, as well as the number of rules assigned to it. And below it, you'll see an `include` tag which pulls in an additional template to render any custom fields associated with the model. In the second column, we've included two more templates to render tags and comments. 74 | 75 | :green_circle: **Tip:** If you're not sure how best to construct the page's layout, there are plenty of examples to reference within NetBox's core templates. 76 | 77 | Let's take a look at our new template! Navigate to the list view again (at ), and follow the link through to a particular access list. You should see something like the image below. 78 | 79 | :blue_square: **Note:** If NetBox complains that the template still does not exist, you may need to manually restart the development server (`manage.py runserver`). 80 | 81 | ![Access list view](/images/step06-accesslist1.png) 82 | 83 | This is nice, but it would be handy to include the access list's assigned rules on the page as well. 84 | 85 | ### Add a Rules Table 86 | 87 | To include the access list rules, we'll need to provide additional _context data_ under the view. Open `views.py` and find the `AccessListView` class. (It should be the first class defined.) Add a `get_extra_context()` method to this class per the code below. 88 | 89 | ```python 90 | class AccessListView(generic.ObjectView): 91 | queryset = models.AccessList.objects.all() 92 | 93 | def get_extra_context(self, request, instance): 94 | table = tables.AccessListRuleTable(instance.rules.all()) 95 | table.configure(request) 96 | 97 | return { 98 | 'rules_table': table, 99 | } 100 | ``` 101 | 102 | This method does three things: 103 | 104 | 1. Instantiate `AccessListRuleTable` with a queryset matching all rules assigned to this access list 105 | 2. Configure the table instance according to the current request (to honor user preferences) 106 | 3. Return a dictionary of context data referencing the table instance 107 | 108 | This makes the table available to our template as the `rules_table` context variable. Let's add it to our template. 109 | 110 | First, we need to import the `render_table` tag from the `django-tables2` library, so that we can render the table as HTML. Add this at the top of the template, immediately below the `{% extends 'generic/object.html' %}` line: 111 | 112 | ``` 113 | {% load render_table from django_tables2 %} 114 | ``` 115 | 116 | Then, immediately above the `{% endblock content %}` line at the end of the file, insert the following template code: 117 | 118 | ``` 119 |
120 |
121 |
122 |
Rules
123 |
124 | {% render_table rules_table %} 125 |
126 |
127 |
128 |
129 | ``` 130 | 131 | After refreshing the access list view in the browser, you should now see the rules table at the bottom of the page. 132 | 133 | ![Access list view with rules table](/images/step06-accesslist2.png) 134 | 135 | ## Create the AccessListRule Template 136 | 137 | Speaking of rules, let's not forget about our `AccessListRule` model: It needs a template too. Create `accesslistrule.html` alongside our first template: 138 | 139 | ```bash 140 | $ edit templates/netbox_access_lists/accesslistrule.html 141 | ``` 142 | 143 | And copy the content below: 144 | 145 | ``` 146 | {% extends 'generic/object.html' %} 147 | 148 | {% block content %} 149 |
150 |
151 |
152 |
Access List Rule
153 |
154 | 155 | 156 | 157 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
Access List 158 | {{ object.access_list }} 159 |
Index{{ object.index }}
Description{{ object.description|placeholder }}
170 |
171 |
172 | {% include 'inc/panels/custom_fields.html' %} 173 | {% include 'inc/panels/tags.html' %} 174 |
175 |
176 |
177 |
Details
178 |
179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 |
Protocol{{ object.get_protocol_display }}
Source Prefix 187 | {% if object.source_prefix %} 188 | {{ object.source_prefix }} 189 | {% else %} 190 | {{ ''|placeholder }} 191 | {% endif %} 192 |
Source Ports{{ object.source_ports|join:", "|placeholder }}
Destination Prefix 201 | {% if object.destination_prefix %} 202 | {{ object.destination_prefix }} 203 | {% else %} 204 | {{ ''|placeholder }} 205 | {% endif %} 206 |
Destination Ports{{ object.destination_ports|join:", "|placeholder }}
Action{{ object.get_action_display }}
217 |
218 |
219 |
220 |
221 | {% endblock content %} 222 | ``` 223 | 224 | You'll probably be able to tell at this point what most of the above template code does, but here are a few details worth mentioning: 225 | 226 | * The URL for the rule's parent access list is retrieved by calling `object.access_list.get_absolute_url()` (the method we added in step five), _without_ the parentheses (a distinction of DTL). This method is used for related prefixes as well. 227 | * NetBox's `placeholder` filter is applied to the rule's description. (This renders a — for empty fields.) 228 | * The `protocol` and `action` attributes are rendered by calling e.g. `object.get_protocol_display()` (again without the parentheses). This is a [Django convention](https://docs.djangoproject.com/en/stable/ref/models/instances/#extra-instance-methods) for static choice fields to return the human-friendly label rather than the raw value. 229 | 230 | ![Access list rule view](/images/step06-accesslistrule.png) 231 | 232 | Feel free to experiment with different layouts and content before proceeding with the next step. 233 | 234 |
235 | 236 | :arrow_left: [Step 5: Views](/tutorial/step05-views.md) | [Step 7: Navigation](/tutorial/step07-navigation.md) :arrow_right: 237 | 238 |
239 | 240 | -------------------------------------------------------------------------------- /tutorial/step07-navigation.md: -------------------------------------------------------------------------------- 1 | # Step 7: Navigation 2 | 3 | So far, we've been manually entering URLs to access our plugin's views. This obviously will not suffice for regular use, so let's see about adding some links to NetBox's navigation menu. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step06-templates`. 6 | 7 | ## Adding Navigation Menu Items 8 | 9 | Begin by creating `navigation.py` in the `netbox_access_lists/` directory. 10 | 11 | ```bash 12 | $ cd netbox_access_lists/ 13 | $ edit navigation.py 14 | ``` 15 | 16 | We'll need to import the `PluginMenuItem` class provided by NetBox to add new menu items; do this at the top of the file. 17 | 18 | ```python 19 | from extras.plugins import PluginMenuItem 20 | ``` 21 | 22 | Next, we'll create a tuple named `menu_items`. This will hold our customized `PluginMenuItem` instances. 23 | 24 | ```python 25 | menu_items = () 26 | ``` 27 | 28 | Let's add a link to the list view for each of our models. This is done by instantiating `PluginMenuItem` with (at minimum) two arguments: 29 | 30 | * `link` - The name of the URL path to which we're linking 31 | * `link_text` - The text of the link 32 | 33 | Create two instances of `PluginMenuItem` within `menu_items`: 34 | 35 | ```python 36 | menu_items = ( 37 | PluginMenuItem( 38 | link='plugins:netbox_access_lists:accesslist_list', 39 | link_text='Access Lists' 40 | ), 41 | PluginMenuItem( 42 | link='plugins:netbox_access_lists:accesslistrule_list', 43 | link_text='Access List Rules' 44 | ), 45 | ) 46 | ``` 47 | 48 | Upon reloading the page, you should see a new "Plugins" section appear at the end of the navigation menu, and below it, a section titled "NetBox Access Lists", with our two links. Navigating to either of these links will highlight the corresponding menu item. 49 | 50 | :blue_square: **Note:** If the menu items do not appear, try restarting the development server (`manage.py runserver`). 51 | 52 | ![Navigation menu items](/images/step07-menu-items1.png) 53 | 54 | That's much more convenient! 55 | 56 | ### Adding Menu Buttons 57 | 58 | While we're at it, we can add direct links to the "add" views for access lists and rules as buttons. We'll need to import two additional classes at the top of `navigation.py`: `PluginMenuButton` and `ButtonColorChoices`. 59 | 60 | ```python 61 | from extras.plugins import PluginMenuButton, PluginMenuItem 62 | from utilities.choices import ButtonColorChoices 63 | ``` 64 | 65 | `PluginMenuButton` is used similarly to `PluginMenuItem`: Instantiate it with the necessary keyword arguments to effect a menu button. These arguments are: 66 | 67 | * `link` - The name of the URL path to which the button links 68 | * `title` - The text displayed when the user hovers over the button 69 | * `icon_class` - CSS class name(s) indicating the icon to display 70 | * `color` - The button's color (choices are provided by `ButtonColorChoices`) 71 | 72 | Create these instances in `navigation.py` _above_ `menu_items`. Because each menu item expects to receive an iterable of button instances, we'll create each of these inside a list. 73 | 74 | ```python 75 | accesslist_buttons = [ 76 | PluginMenuButton( 77 | link='plugins:netbox_access_lists:accesslist_add', 78 | title='Add', 79 | icon_class='mdi mdi-plus-thick', 80 | color=ButtonColorChoices.GREEN 81 | ) 82 | ] 83 | 84 | accesslistrule_buttons = [ 85 | PluginMenuButton( 86 | link='plugins:netbox_access_lists:accesslistrule_add', 87 | title='Add', 88 | icon_class='mdi mdi-plus-thick', 89 | color=ButtonColorChoices.GREEN 90 | ) 91 | ] 92 | ``` 93 | 94 | The buttons can then be passed to the menu items via the `buttons` keyword argument: 95 | 96 | ```python 97 | menu_items = ( 98 | PluginMenuItem( 99 | link='plugins:netbox_access_lists:accesslist_list', 100 | link_text='Access Lists', 101 | buttons=accesslist_buttons 102 | ), 103 | PluginMenuItem( 104 | link='plugins:netbox_access_lists:accesslistrule_list', 105 | link_text='Access List Rules', 106 | buttons=accesslistrule_buttons 107 | ), 108 | ) 109 | ``` 110 | 111 | Now we should see green "add" buttons appear next to our menu links. 112 | 113 | ![Navigation menu items with buttons](/images/step07-menu-items2.png) 114 | 115 |
116 | 117 | :arrow_left: [Step 6: Templates](/tutorial/step06-templates.md) | [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) :arrow_right: 118 | 119 |
120 | 121 | -------------------------------------------------------------------------------- /tutorial/step08-filter-sets.md: -------------------------------------------------------------------------------- 1 | # Step 8: Filter Sets 2 | 3 | Filters enable users to request only a specific subset of objects matching a query; when filtering the sites list by status or region, for instance. NetBox employs the [`django-filters`](https://django-filter.readthedocs.io/en/stable/) library to build and apply filter sets for models. We can create filter sets to enable this same functionality for our plugin as well. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step07-navigation`. 6 | 7 | ## Create a Filter Set 8 | 9 | Begin by creating `filtersets.py` in the `netbox_access_lists/` directory. 10 | 11 | ```bash 12 | $ cd netbox_access_lists/ 13 | $ edit filtersets.py 14 | ``` 15 | 16 | At the top of this file, we'll import NetBox's `NetBoxModelFilterSet` class, which will serve as the base class for our filter set, as well as our `AccessListRule` model. (In the interest of brevity, we're only going to create a filter set for one model, but it should be clear how to replicate this approach for the `AccessList` model as well.) 17 | 18 | ```python 19 | from netbox.filtersets import NetBoxModelFilterSet 20 | from .models import AccessListRule 21 | ``` 22 | 23 | Next, create a class named `AccessListRuleFilterSet` subclassing `NetBoxModelFilterSet`. Within this class, create a child `Meta` class and define the filter set's `model` and `fields` attributes. (You may notice this looks familiar; it is very similar to the process for building a model form.) The `fields` parameter should list all the model fields against which we might want to filter. 24 | 25 | ```python 26 | class AccessListRuleFilterSet(NetBoxModelFilterSet): 27 | 28 | class Meta: 29 | model = AccessListRule 30 | fields = ('id', 'access_list', 'index', 'protocol', 'action') 31 | ``` 32 | 33 | `NetBoxModelFilterSet` handles some important functions for us, including support for filtering by custom field values and tags. It also creates a general-purpose `q` filter which invokes the `search()` method. (By default, this does nothing.) We can override this method to define our general-purpose search logic. Let's add a `search` method after the `Meta` child class to override the default behavior. 34 | 35 | ```python 36 | def search(self, queryset, name, value): 37 | return queryset.filter(description__icontains=value) 38 | ``` 39 | 40 | This will return all rules whose description contains the queried string. Of course, you're free to extend this to match other fields as well, but for our purposes this should be sufficient. 41 | 42 | ## Create a Filter Form 43 | 44 | The filter set handles the "behind the scenes" process of filtering queries, but we also need to create a form class to render the filter fields in the UI. We'll add this to `forms.py`. First, import Django's `forms` module (which will provide the field classes we need) and append `NetBoxModelFilterSetForm` to the existing import statement for `netbox.forms`: 45 | 46 | ```python 47 | from django import forms 48 | # ... 49 | from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm 50 | ``` 51 | 52 | Then create a form class named `AccessListRuleFilterForm` subclassing `NetBoxModelFilterSetForm` and declare an attribute named `model` referencing `AccessListRule` (which has already been imported for one of the existing forms). 53 | 54 | ```python 55 | class AccessListRuleFilterForm(NetBoxModelFilterSetForm): 56 | model = AccessListRule 57 | ``` 58 | 59 | :blue_square: **Note:** Note that the `model` attribute is declared directly under the class: We don't need a `Meta` child class. 60 | 61 | Next, we need to define a form field for each filter that we want to appear in the UI. Let's start with the `access_list` filter: This references a related object, so we'll want to use `ModelMultipleChoiceField` (to allow users to filter by multiple objects). Add the form field with the same name as its peer filter, specifying the queryset to use when fetching related objects. 62 | 63 | ```python 64 | access_list = forms.ModelMultipleChoiceField( 65 | queryset=AccessList.objects.all(), 66 | required=False 67 | ) 68 | ``` 69 | 70 | Notice that we've also set `required=False`: This should be the case for _all_ fields in a filter form, because filters are never mandatory. 71 | 72 | :blue_square: **Note:** We're using Django's `ModelMultipleChoiceField` class for this field instead of NetBox's `DynamicModelChoiceField` because the latter requires a functional REST API endpoint for the model. Once we implement a REST API in step nine, you're free to revisit this form and change `access_list` to a `DynamicModelChoiceField`. 73 | 74 | Next we'll add a field for the `position` filter: This is an integer field, so `IntegerField` should work nicely: 75 | 76 | ```python 77 | index = forms.IntegerField( 78 | required=False 79 | ) 80 | ``` 81 | 82 | Finally, we'll add fields for the `protocol` and `action` choice-based filters. `MultipleChoiceField` should be used to allow users to select one or more choices. We must pass the set of valid choices when declaring these fields, so first extend the relevant import statement at the top of `forms.py`: 83 | 84 | ```python 85 | from .models import AccessList, AccessListRule, ActionChoices, ProtocolChoices 86 | ``` 87 | 88 | Then add the form fields to `AccessListRuleFilterForm`: 89 | 90 | ```python 91 | protocol = forms.MultipleChoiceField( 92 | choices=ProtocolChoices, 93 | required=False 94 | ) 95 | action = forms.MultipleChoiceField( 96 | choices=ActionChoices, 97 | required=False 98 | ) 99 | ``` 100 | 101 | ## Update the View 102 | 103 | The last step before we can use our new filter set and form is to enable them under the model's list view. Open `views.py` and extend the last import statement to include the `filtersets` module: 104 | 105 | ```python 106 | from . import filtersets, forms, models, tables 107 | ``` 108 | 109 | Then, add the `filterset` and `filterset_form` attributes to `AccessListRuleListView`: 110 | 111 | ```python 112 | class AccessListRuleListView(generic.ObjectListView): 113 | queryset = models.AccessListRule.objects.all() 114 | table = tables.AccessListRuleTable 115 | filterset = filtersets.AccessListRuleFilterSet 116 | filterset_form = forms.AccessListRuleFilterForm 117 | ``` 118 | 119 | After ensuring the development server has restarted, navigate to the rules list view in the browser. You should now see a "Filters" tab next to the "Results" tab. Under it we'll find the four fields we created on `AccessListRuleFilterForm`, as well as the built-in "search" field. 120 | 121 | ![Access list rules filter form](/images/step08-filter-form.png) 122 | 123 | If you haven't already, create a few more access lists and rules, and experiment with the filters. Consider how you might filter by additional fields, or add more complex logic to the filter set. 124 | 125 | :green_circle: **Tip:** You may notice that we did not add a form field for the model's `id` filter: This is because it is unlikely to be useful for a human utilizing the UI. However, we still want to support filtering object by their primary keys, because it _is_ very helpful for consumers of NetBox's REST API, which we'll cover next. 126 | 127 |
128 | 129 | :arrow_left: [Step 7: Navigation](/tutorial/step07-navigation.md) | [Step 9: REST API](/tutorial/step09-rest-api.md) :arrow_right: 130 | 131 |
132 | 133 | -------------------------------------------------------------------------------- /tutorial/step09-rest-api.md: -------------------------------------------------------------------------------- 1 | # Step 9: REST API 2 | 3 | The REST API enables powerful integration with other systems which exchange data with NetBox. It is powered by the [Django REST Framework](https://www.django-rest-framework.org/) (DRF), which is _not_ a component of Django itself. In this tutorial, we'll see how we can extend NetBox's REST API to serve our plugin. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step08-filter-sets`. 6 | 7 | Our API code will live in the `api/` directory under `netbox_access_lists/`. Let's go ahead and create that as well as an `__init__.py` file now: 8 | 9 | ```bash 10 | $ cd netbox_access_lists/ 11 | $ mkdir api 12 | $ touch api/__init__.py 13 | ``` 14 | 15 | ## Create Model Serializers 16 | 17 | Serializers are somewhat analogous to forms: They control the translation of client data to and from Python objects, while Django itself handles the database abstraction. We need to create a serializer for each of our models. Begin by creating `serializers.py` in the `api/` directory. 18 | 19 | ```bash 20 | $ edit api/serializers.py 21 | ``` 22 | 23 | At the top of this file, we need to import the `serializers` module from the `rest_framework` library, as well as NetBox's `NetBoxModelSerializer` class and our plugin's own models: 24 | 25 | ```python 26 | from rest_framework import serializers 27 | 28 | from netbox.api.serializers import NetBoxModelSerializer 29 | from ..models import AccessList, AccessListRule 30 | ``` 31 | 32 | ### Create AccessListSerializer 33 | 34 | First, we'll create a serializer for `AccessList`, subclassing `NetBoxModelSerializer`. Much like when creating a model form, we'll create a child `Meta` class under the serializer specifying the associated `model` and the `fields` to be included. 35 | 36 | ```python 37 | class AccessListSerializer(NetBoxModelSerializer): 38 | 39 | class Meta: 40 | model = AccessList 41 | fields = ( 42 | 'id', 'display', 'name', 'default_action', 'comments', 'tags', 'custom_fields', 'created', 43 | 'last_updated', 44 | ) 45 | ``` 46 | 47 | It's worth discussing each of the fields we've named above. `id` is the model's primary key; it should always be included with every serializer, as it provides a guaranteed method of uniquely identifying objects. The `display` field is built into `NetBoxModelSerializer`: It is a read-only field which returns a string representation of the object. This is useful for populating form field dropdowns, for instance. 48 | 49 | The `name`, `default_action`, and `comments` fields are declared on the `AccessList` model. `tags` provides access to the object's tag manager, and `custom_fields` includes its custom field data; both of these are provided by `NetBoxModelSerializer`. Finally, the `created` and `last_updated` are read-only fields built into `NetBoxModel`. 50 | 51 | Our serializer will inspect the model to generate the necessary fields automatically, however there's one field that we need to add manually. Every serializer should include a read-only `url` field which contains the URL where the object can be reached; think of it as similar to a model's `get_absolute_url()` method. To add this, we'll use DRF's `HyperlinkedIdentityField`. Add it above the `Meta` child class: 52 | 53 | ```python 54 | class AccessListSerializer(NetBoxModelSerializer): 55 | url = serializers.HyperlinkedIdentityField( 56 | view_name='plugins-api:netbox_access_lists-api:accesslist-detail' 57 | ) 58 | ``` 59 | 60 | When invoking the field class, we need to specify the appropriate view name. Note that this view doesn't actually exist yet; we'll create it a bit later. 61 | 62 | Remember back in step three when we added a table column showing the number of rules assigned to each access list? That was handy. Let's add a serializer field for it too! Add this directly below the `url` field: 63 | 64 | ```python 65 | rule_count = serializers.IntegerField(read_only=True) 66 | ``` 67 | 68 | Just as with the table column, we'll rely on our view (to be defined next) to annotate the rule count for each access list on the underlying queryset. 69 | 70 | Finally, we need to add both `url` and `rule_count` to `Meta.fields`: 71 | 72 | ```python 73 | class Meta: 74 | model = AccessList 75 | fields = ( 76 | 'id', 'url', 'display', 'name', 'default_action', 'comments', 'tags', 'custom_fields', 'created', 77 | 'last_updated', 'rule_count', 78 | ) 79 | ``` 80 | 81 | :green_circle: **Tip:** The order in which fields are listed determines the order in which they appear in the object's API representation. 82 | 83 | ### Create AccessListRuleSerializer 84 | 85 | We also need to create a serializer for `AccessListRule`. Add it to `serializers.py` below `AccessListSerializer`. As with the first serializer, we'll add a `Meta` class to define the model and fields, and a `url` field. 86 | 87 | ```python 88 | class AccessListRuleSerializer(NetBoxModelSerializer): 89 | url = serializers.HyperlinkedIdentityField( 90 | view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' 91 | ) 92 | 93 | class Meta: 94 | model = AccessListRule 95 | fields = ( 96 | 'id', 'url', 'display', 'access_list', 'index', 'protocol', 'source_prefix', 'source_ports', 97 | 'destination_prefix', 'destination_ports', 'action', 'tags', 'custom_fields', 'created', 98 | 'last_updated', 99 | ) 100 | ``` 101 | 102 | There's an additional consideration when referencing related objects in a serializer. By default, the serializer will return only the primary key of the related object; its numeric ID. This requires the client to make additional API requests in order to determine _any_ other information about the related object. It is convenient to include on the serializer some information about the related object, such as its name and URL, automatically. We can do this by using a _nested serializer_. 103 | 104 | For instance, the `source_prefix` and `destination_prefix` fields both reference NetBox's core `ipam.Prefix` model. We can extend `AccessListRuleSerializer` to use NetBox's nested serializer for this model: 105 | 106 | ```python 107 | from ipam.api.serializers import NestedPrefixSerializer 108 | # ... 109 | class AccessListRuleSerializer(NetBoxModelSerializer): 110 | url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail') 111 | source_prefix = NestedPrefixSerializer() 112 | destination_prefix = NestedPrefixSerializer() 113 | ``` 114 | 115 | Now, our serializer will include an abridged representation of the source and/or destination prefixes for the object. We should do this with the `access_list` field as well, however we'll first need to create a nested serializer for the `AccessList` model. 116 | 117 | ### Create Nested Serializers 118 | 119 | Begin by importing NetBox's `WritableNestedSerializer` class. This will serve as the base class for our nested serializers. 120 | 121 | ```python 122 | from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer 123 | ``` 124 | 125 | Then, create two nested serializer classes, one for each of our plugin's models. Each of these will have a `url` field and `Meta` child class like the regular serializers, however the `Meta.fields` attribute for each is limited to a bare minimum of fields: `id`, `url`, `display`, and a supplementary human-friendly identifier. Add these in `serializers.py` _above_ the regular serializers (because we need to define `NestedAccessListSerializer` before we can reference it). 126 | 127 | ```python 128 | class NestedAccessListSerializer(WritableNestedSerializer): 129 | url = serializers.HyperlinkedIdentityField( 130 | view_name='plugins-api:netbox_access_lists-api:accesslist-detail' 131 | ) 132 | 133 | class Meta: 134 | model = AccessList 135 | fields = ('id', 'url', 'display', 'name') 136 | 137 | class NestedAccessListRuleSerializer(WritableNestedSerializer): 138 | url = serializers.HyperlinkedIdentityField( 139 | view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' 140 | ) 141 | 142 | class Meta: 143 | model = AccessListRule 144 | fields = ('id', 'url', 'display', 'index') 145 | ``` 146 | 147 | Now we can override the `access_list` field on `AccessListRuleSerializer` to use the nested serializer: 148 | 149 | ```python 150 | class AccessListRuleSerializer(NetBoxModelSerializer): 151 | url = serializers.HyperlinkedIdentityField( 152 | view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' 153 | ) 154 | access_list = NestedAccessListSerializer() 155 | source_prefix = NestedPrefixSerializer() 156 | destination_prefix = NestedPrefixSerializer() 157 | ``` 158 | 159 | ## Create the Views 160 | 161 | Next, we need to create views to handle the API logic. Just as serializers are roughly analogous to forms, API views work similarly to the UI views that we created in step five. However, because API functionality is highly standardized, view creation is substantially simpler: We generally need only to create a single _view set_ for each model. A view set is a single class that can handle the view, add, change, and delete operations which each require dedicated views under the UI. 162 | 163 | Start by creating `api/views.py` and importing NetBox's `NetBoxModelViewSet` class, as well as our plugin's `models` and `filtersets` modules, and our serializers. 164 | 165 | ```python 166 | from netbox.api.viewsets import NetBoxModelViewSet 167 | 168 | from .. import filtersets, models 169 | from .serializers import AccessListSerializer, AccessListRuleSerializer 170 | ``` 171 | 172 | First we'll create a view set for access lists, by inheriting from `NetBoxModelViewSet` and defining its `queryset` and `serializer_class` attributes. (Note that we're prefetching assigned tags for the queryset.) 173 | 174 | ```python 175 | class AccessListViewSet(NetBoxModelViewSet): 176 | queryset = models.AccessList.objects.prefetch_related('tags') 177 | serializer_class = AccessListSerializer 178 | ``` 179 | 180 | Recall that we added a `rule_count` field to `AccessListSerializer`; let's annotate the queryset appropriately to ensure that field gets populated (just as we did for the table column in step five). Remember to import Django's `Count` utility class. 181 | 182 | ```python 183 | from django.db.models import Count 184 | # ... 185 | class AccessListViewSet(NetBoxModelViewSet): 186 | queryset = models.AccessList.objects.prefetch_related('tags').annotate( 187 | rule_count=Count('rules') 188 | ) 189 | serializer_class = AccessListSerializer 190 | ``` 191 | 192 | Next, we'll add a view set for rules. In addition to `queryset` and `serializer_class`, we'll attach the filter set for this model as `filterset_class`. Note that we're also prefetching all related object fields in addition to tags to improve performance when listing many objects. 193 | 194 | ```python 195 | class AccessListRuleViewSet(NetBoxModelViewSet): 196 | queryset = models.AccessListRule.objects.prefetch_related( 197 | 'access_list', 'source_prefix', 'destination_prefix', 'tags' 198 | ) 199 | serializer_class = AccessListRuleSerializer 200 | filterset_class = filtersets.AccessListRuleFilterSet 201 | ``` 202 | 203 | ## Create the Endpoint URLs 204 | 205 | Finally, we'll create our API endpoint URLs. This works a bit differently from UI views: Instead of defining a series of paths, we instantiate a _router_ and register each view set to it. 206 | 207 | Create `api/urls.py` and import NetBox's `NetBoxRouter` and our API views: 208 | 209 | ```python 210 | from netbox.api.routers import NetBoxRouter 211 | from . import views 212 | ``` 213 | 214 | Next, we'll define an `app_name`. This will be used to resolve API view names for our plugin. 215 | 216 | ```python 217 | app_name = 'netbox_access_list' 218 | ``` 219 | 220 | Then, we create a `NetBoxRouter` instance and register each view with it using our desired URL. These are the endpoints that will be available under `/api/plugins/access-lists/`. 221 | 222 | ```python 223 | router = NetBoxRouter() 224 | router.register('access-lists', views.AccessListViewSet) 225 | router.register('access-list-rules', views.AccessListRuleViewSet) 226 | ``` 227 | 228 | Finally, we expose the router's `urls` attribute as `urlpatterns` so that it will be detected by the plugins framework. 229 | 230 | ```python 231 | urlpatterns = router.urls 232 | ``` 233 | 234 | :green_circle: **Tip:** The base URL for our plugin's REST API endpoints is determined by the `base_url` attribute of the plugin config class that we created in step one. 235 | 236 | With all of our REST API components now in place, we should be able to make API requests. (Note that you may first need to provision a token for authentication.) You can quickly verify that our endpoints are working properly by navigating to in your browser while logged into NetBox. You should see the two available endpoints; clicking on either will return a list of objects. 237 | 238 | :blue_square: **Note:** If the REST API endpoints do not load, try restarting the development server (`manage.py runserver`). 239 | 240 | ![REST API - Root view](/images/step09-rest-api1.png) 241 | 242 | ![REST API - Access list rules](/images/step09-rest-api2.png) 243 | 244 |
245 | 246 | :arrow_left: [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) | [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) :arrow_right: 247 | 248 |
249 | 250 | -------------------------------------------------------------------------------- /tutorial/step10-graphql-api.md: -------------------------------------------------------------------------------- 1 | # Step 10: GraphQL API 2 | 3 | In addition to its REST API, NetBox also features a [GraphQL](https://graphql.org/) API. This can be used to conveniently request arbitrary collections of data about NetBox objects. NetBox's GraphQL API is built using the [Graphene](https://graphene-python.org/) and [`graphene-django`](https://docs.graphene-python.org/projects/django/en/latest/) library. 4 | 5 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step09-rest-api`. 6 | 7 | Begin by creating `graphql.py`. This will hold our object type and query classes. 8 | 9 | ```bash 10 | $ cd netbox_access_lists/ 11 | $ edit graphql.py 12 | ``` 13 | 14 | We'll need to import several resources. First we need Graphene's `ObjectType` class, as well as NetBox's custom `NetBoxObjectType` which inherits from it. (The latter will be used for our model types.) We also need the `ObjectField` and `ObjectListField` classes provided by NetBox for our query. And finally, import our plugin's `models` and `filtersets` modules. 15 | 16 | ```python 17 | from graphene import ObjectType 18 | from netbox.graphql.types import NetBoxObjectType 19 | from netbox.graphql.fields import ObjectField, ObjectListField 20 | from . import filtersets, models 21 | ``` 22 | 23 | ## Create the Object Types 24 | 25 | Subclass `NetBoxObjectType` to create two object type classes, one for each of our models. Just like with the REST API serilizers, create a child `Meta` class on each defining its `model` and `fields`. However, instead of explicitly listing each field by name, in our case we can use the special value `__all__` to indicate that we want to include all available model fields. Additionally, declare `filterset_class` on `AccessListRuleType` to attach the filter set. 26 | 27 | ```python 28 | class AccessListType(NetBoxObjectType): 29 | 30 | class Meta: 31 | model = models.AccessList 32 | fields = '__all__' 33 | 34 | 35 | class AccessListRuleType(NetBoxObjectType): 36 | 37 | class Meta: 38 | model = models.AccessListRule 39 | fields = '__all__' 40 | filterset_class = filtersets.AccessListRuleFilterSet 41 | ``` 42 | 43 | ## Create the Query 44 | 45 | Then we need to create our query class. Subclass Graphene's `ObjectType` class and define two fields for each model: an object field and a list field. 46 | 47 | ```python 48 | class Query(ObjectType): 49 | access_list = ObjectField(AccessListType) 50 | access_list_list = ObjectListField(AccessListType) 51 | 52 | access_list_rule = ObjectField(AccessListRuleType) 53 | access_list_rule_list = ObjectListField(AccessListRuleType) 54 | ``` 55 | 56 | Then we just need to expose our query class to the plugins framework as `schema`: 57 | 58 | ```python 59 | schema = Query 60 | ``` 61 | 62 | :green_circle: **Tip:** The path to the query class can be changed by setting `graphql_schema` in the plugin's configuration class. 63 | 64 | To try out the GraphQL API, open `` in a browser and enter the following query: 65 | 66 | ``` 67 | query { 68 | access_list_list { 69 | id 70 | name 71 | rules { 72 | index 73 | action 74 | description 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | You should receive a response showing the ID, name, and rules for each access list in NetBox. Each rule will list its index, action, and description. Experiment with different queries to see what other data you can request. (Refer back to the model definitions for inspiration.) 81 | 82 | ![GraphiQL interface](/images/step10-graphiql.png) 83 | 84 | This completes the plugin development tutorial. Well done! Now you're all set to make a plugin of your own! 85 | 86 |
87 | 88 | :arrow_left: [Step 9: REST API](/tutorial/step09-rest-api.md) | [Step 11: Search](/tutorial/step11-search.md) :arrow_right: 89 | 90 |
91 | 92 | -------------------------------------------------------------------------------- /tutorial/step11-search.md: -------------------------------------------------------------------------------- 1 | # Step 11: NetBox v3.4 Features 2 | 3 | NetBox version 3.4, released in December 2022, introduced a greatly improved global search engine, which includes the ability for plugins to register their own models. In this step, we'll add search indexers for our custom models so that they appear in NetBox's global search results. 4 | 5 | :warning: **Warning:** This feature requires NetBox v3.4 or later. If you haven't already, be sure to set `min_version = '3.4.0'` in `NetBoxAccessListsConfig`. 6 | 7 | :blue_square: **Note:** If you skipped the previous step, run `git checkout step10-graphql`. 8 | 9 | ## Create Search Indexes 10 | 11 | Our plugin has two models: `AccessList` and `AccessListRule`. We'd like users to be able to search instances of both models using NetBox's global search feature. To enable this, we need to declare and register a `SearchIndex` subclass for each model. 12 | 13 | Begin by creating `search.py` in the plugin's root directory, alongside `models.py`. 14 | 15 | ```bash 16 | $ cd netbox_access_lists/ 17 | $ edit search.py 18 | ``` 19 | 20 | Within this file, we'll import NetBox's `SearchIndex` class as well as our own models. Then, we'll create a subclass of `SearchIndex` for each model: 21 | 22 | ```python 23 | from netbox.search import SearchIndex 24 | from .models import AccessList, AccessListRule 25 | 26 | class AccessListIndex(SearchIndex): 27 | model = AccessList 28 | 29 | class AccessListRuleIndex(SearchIndex): 30 | model = AccessListRule 31 | ``` 32 | 33 | There's a bit more to enabling search, though. We also need to tell NetBox which fields to search for each model, and how important each field is (also known as its _precedence_). The latter is accomplished by assigning a numerical weight. 34 | 35 | Consider our `AccessList` model. It has three interesting database fields: `name`, `default_action`, and `comments`. How should we treat these when searching for objects in NetBox? This can be somewhat subjective, but generally we want to assign higher precedence (_lower_ weights) to important fields, and omit fields that we don't care about. If you're unsure what weights to assign, have a look around the core NetBox code base for similar examples. 36 | 37 | * `name`: This is an important field, so we'll give it a high precedence of `100`. 38 | * `default_action` This is a choice selection field. While very useful for _filtering_, we wouldn't typically expect users to search for these values. We'll exclude this field from the search index. 39 | * `comments`: It's always recommended to include user comments in the search index, however we'll assign this field a much lower precedence of `5000` as any matches are less likely to be pertinent. 40 | 41 | After selecting our search fields and their precedences, we should have something like this: 42 | 43 | ```python 44 | class AccessListIndex(SearchIndex): 45 | model = AccessList 46 | fields = ( 47 | ('name', 100), 48 | ('comments', 5000), 49 | ) 50 | 51 | class AccessListRuleIndex(SearchIndex): 52 | model = AccessListRule 53 | fields = ( 54 | ('description', 500), 55 | ) 56 | ``` 57 | 58 | Why did we exclude the source and destination parameters from `AccessListRuleIndex`? The source and destination prefixes are related objects, so we want to avoid caching their values locally: If the related object is changed, our cached copy can become outdated. And we omit the source and destination port numbers because matching on common integer values can produce a ton of irrelevant search results. All of these values are better matched using specific filters rather than general purpose search. 59 | 60 | ## Register the Indexers 61 | 62 | Finally, we need to register our indexers so that NetBox knows to run them. At the top of `search.py`, import the `register_search` decorator. Then, use it to wrap both of our index classes: 63 | 64 | ```python 65 | from netbox.search import SearchIndex, register_search 66 | from .models import AccessList, AccessListRule 67 | 68 | @register_search 69 | class AccessListIndex(SearchIndex): 70 | model = AccessList 71 | fields = ( 72 | ('name', 100), 73 | ('comments', 5000), 74 | ) 75 | 76 | @register_search 77 | class AccessListRuleIndex(SearchIndex): 78 | model = AccessListRule 79 | fields = ( 80 | ('description', 500), 81 | ) 82 | ``` 83 | 84 | With our indexers now registered, we can run the `reindex` management command to index any existing objects. (New objects created from this point forward will be registered automatically upon creation.) 85 | 86 | ``` 87 | $ ./manage.py reindex netbox_access_lists 88 | Reindexing 2 models. 89 | Indexing models 90 | netbox_access_lists.accesslist... 1 entries cached. 91 | netbox_access_lists.accesslistrule... 3 entries cached. 92 | ``` 93 | 94 | Now we can search for access lists and rules using NetBox's global search function. 95 | 96 | ![Search results](/images/step11-search-results.png) 97 | 98 |
99 | 100 | :arrow_left: [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) 101 | 102 |
103 | --------------------------------------------------------------------------------