diff --git a/README.md b/README.md index ab40900..953552d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # NetBox Plugin Development Tutorial -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. +This guide seeks to demonstrate the process of developing a custom plugin for NetBox v4.5 or later. +By following each of the prescribed steps, the reader will create from scratch a simple plugin for managing access lists in NetBox, using all major components of the NetBox plugin framework. -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. +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, check out the `step04-forms` branch. ### Prerequisites @@ -15,17 +18,19 @@ Before attempting to create a plugin, please assess your personal ability. Plugi ### Contents -* [Step 1: Initial Setup](/tutorial/step01-initial-setup.md) :arrow_left: Start here! +* [Step 0: Initial Setup](/tutorial/step00-initial-setup.md) :arrow_left: Start here! +* [Step 1: Plugin Configuration](/tutorial/step01-plugin-configuration.md) * [Step 2: Models](/tutorial/step02-models.md) * [Step 3: Tables](/tutorial/step03-tables.md) * [Step 4: Forms](/tutorial/step04-forms.md) * [Step 5: Views](/tutorial/step05-views.md) -* [Step 6: Templates](/tutorial/step06-templates.md) -* [Step 7: Navigation](/tutorial/step07-navigation.md) -* [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) -* [Step 9: REST API](/tutorial/step09-rest-api.md) -* [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) -* [Step 11: Search](/tutorial/step11-search.md) +* [Step 6: URLs](/tutorial/step06-urls.md) +* [Step 7: Templates](/tutorial/step07-templates.md) +* [Step 8: Navigation](/tutorial/step08-navigation.md) +* [Step 9: Filter Sets](/tutorial/step09-filter-sets.md) +* [Step 10: REST API](/tutorial/step10-rest-api.md) +* [Step 11: GraphQL API](/tutorial/step11-graphql-api.md) +* [Step 12: Search](/tutorial/step12-search.md) ### Reference diff --git a/images/step01-django-admin-plugins.png b/images/step01-django-admin-plugins.png deleted file mode 100644 index 4ae70e9..0000000 Binary files a/images/step01-django-admin-plugins.png and /dev/null differ diff --git a/images/step01-netbox-plugin-list.png b/images/step01-netbox-plugin-list.png new file mode 100644 index 0000000..ffe2c78 Binary files /dev/null and b/images/step01-netbox-plugin-list.png differ diff --git a/images/step05-accesslist-form.png b/images/step05-accesslist-form.png deleted file mode 100644 index efa4094..0000000 Binary files a/images/step05-accesslist-form.png and /dev/null differ diff --git a/images/step05-accesslist-list.png b/images/step05-accesslist-list.png deleted file mode 100644 index 0bb5687..0000000 Binary files a/images/step05-accesslist-list.png and /dev/null differ diff --git a/images/step06-accesslist-form.png b/images/step06-accesslist-form.png new file mode 100644 index 0000000..4f7031b Binary files /dev/null and b/images/step06-accesslist-form.png differ diff --git a/images/step06-accesslist-list.png b/images/step06-accesslist-list.png new file mode 100644 index 0000000..b7f4929 Binary files /dev/null and b/images/step06-accesslist-list.png differ diff --git a/images/step06-accesslist1.png b/images/step06-accesslist1.png deleted file mode 100644 index 3e645a8..0000000 Binary files a/images/step06-accesslist1.png and /dev/null differ diff --git a/images/step06-accesslist2.png b/images/step06-accesslist2.png deleted file mode 100644 index cd8b1f4..0000000 Binary files a/images/step06-accesslist2.png and /dev/null differ diff --git a/images/step06-accesslistrule.png b/images/step06-accesslistrule.png deleted file mode 100644 index d60fb83..0000000 Binary files a/images/step06-accesslistrule.png and /dev/null differ diff --git a/images/step07-accesslist1.png b/images/step07-accesslist1.png new file mode 100644 index 0000000..bc352ef Binary files /dev/null and b/images/step07-accesslist1.png differ diff --git a/images/step07-accesslist2.png b/images/step07-accesslist2.png new file mode 100644 index 0000000..40109d7 Binary files /dev/null and b/images/step07-accesslist2.png differ diff --git a/images/step07-accesslist3.png b/images/step07-accesslist3.png new file mode 100644 index 0000000..cc4f1fb Binary files /dev/null and b/images/step07-accesslist3.png differ diff --git a/images/step07-accesslistrule.png b/images/step07-accesslistrule.png new file mode 100644 index 0000000..0e51de7 Binary files /dev/null and b/images/step07-accesslistrule.png differ diff --git a/images/step07-menu-items1.png b/images/step07-menu-items1.png deleted file mode 100644 index 78c8acb..0000000 Binary files a/images/step07-menu-items1.png and /dev/null differ diff --git a/images/step07-menu-items2.png b/images/step07-menu-items2.png deleted file mode 100644 index add30f4..0000000 Binary files a/images/step07-menu-items2.png and /dev/null differ diff --git a/images/step08-filter-form.png b/images/step08-filter-form.png deleted file mode 100644 index 9b71230..0000000 Binary files a/images/step08-filter-form.png and /dev/null differ diff --git a/images/step08-menu-items1.png b/images/step08-menu-items1.png new file mode 100644 index 0000000..a5b9fb6 Binary files /dev/null and b/images/step08-menu-items1.png differ diff --git a/images/step08-menu-items2.png b/images/step08-menu-items2.png new file mode 100644 index 0000000..2472474 Binary files /dev/null and b/images/step08-menu-items2.png differ diff --git a/images/step09-filter-form.png b/images/step09-filter-form.png new file mode 100644 index 0000000..c3d490c Binary files /dev/null and b/images/step09-filter-form.png differ diff --git a/images/step09-rest-api1.png b/images/step09-rest-api1.png deleted file mode 100644 index df54277..0000000 Binary files a/images/step09-rest-api1.png and /dev/null differ diff --git a/images/step09-rest-api2.png b/images/step09-rest-api2.png deleted file mode 100644 index 0b58d29..0000000 Binary files a/images/step09-rest-api2.png and /dev/null differ diff --git a/images/step10-graphiql.png b/images/step10-graphiql.png deleted file mode 100644 index 82b40d7..0000000 Binary files a/images/step10-graphiql.png and /dev/null differ diff --git a/images/step10-rest-api1.png b/images/step10-rest-api1.png new file mode 100644 index 0000000..e788751 Binary files /dev/null and b/images/step10-rest-api1.png differ diff --git a/images/step10-rest-api2.png b/images/step10-rest-api2.png new file mode 100644 index 0000000..e29fbff Binary files /dev/null and b/images/step10-rest-api2.png differ diff --git a/images/step11-graphiql.png b/images/step11-graphiql.png new file mode 100644 index 0000000..30df535 Binary files /dev/null and b/images/step11-graphiql.png differ diff --git a/images/step11-search-results.png b/images/step11-search-results.png deleted file mode 100644 index 5982a86..0000000 Binary files a/images/step11-search-results.png and /dev/null differ diff --git a/images/step12-search-results.png b/images/step12-search-results.png new file mode 100644 index 0000000..eb6d026 Binary files /dev/null and b/images/step12-search-results.png differ diff --git a/tutorial/step00-initial-setup.md b/tutorial/step00-initial-setup.md new file mode 100644 index 0000000..5c9a26b --- /dev/null +++ b/tutorial/step00-initial-setup.md @@ -0,0 +1,105 @@ +# Step 0: Initial Setup + +Before we start writing our plugin, we need a working local NetBox development environment. In this step we'll: + +- Install (or verify) a local NetBox dev setup +- Enable development-friendly settings +- Optionally clone the demo plugin repository so you can follow along (or jump between tutorial steps) + +:warning: **Warning:** This tutorial requires **NetBox v4.5 or later**. Attempting to use an earlier NetBox release will not work. + +## Install the NetBox Development Environment + +Plugin development requires a local installation of NetBox. + +If you don't already have NetBox installed, follow the official [installation instructions](https://netbox.readthedocs.io/en/stable/installation/). For development, installing from the Git repository is recommended because it makes it easy to switch between NetBox versions/tags. + +:green_circle: **Tip:** If you're setting this up *only* for local development, you can usually stop once you can successfully start the development server (e.g. `manage.py runserver`). You can always come back later and complete any optional production hardening steps. + +Before you start developing, make sure NetBox is running in development mode with debugging enabled. From your NetBox installation root, edit `netbox/netbox/configuration.py` and set: + +```python +DEBUG = True +DEVELOPER = True +``` + +These settings ensure that: + +- static assets can be served by the development server +- you'll get full tracebacks in the browser when an error occurs + +:blue_square: **Note:** If NetBox is already running, restart the development server after changing these settings. + +:warning: **Warning:** Never enable `DEBUG = True` in a production environment. + +## Prepare the Plugin Development Environment + +A NetBox plugin is a standard Python package that contains a NetBox-specific Django application. Your plugin project should be structured according to the [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/). + +There are two ways to follow this tutorial: + +1. **Create the directory structure manually** (you can optionally use the demo repository as a reference). +2. **Use the cookiecutter template** ([cookiecutter-netbox-plugin](https://github.com/netbox-community/cookiecutter-netbox-plugin)) to generate a new plugin project. + +In this tutorial, we'll use the **manual approach**, building up a project that will eventually look similar to this: + +```text +. +├── netbox_access_lists +│ ├── api +│ │ ├── __init__.py +│ │ ├── serializers.py +│ │ ├── urls.py +│ │ └── views.py +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── graphql +│ │ ├── enums.py +│ │ ├── filters.py +│ │ ├── __init__.py +│ │ ├── schema.py +│ │ └── types.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── search.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +:blue_square: **Note:** You don't need to create all of this up front. We'll add files and directories as we go. + +If you prefer to use the cookiecutter template, you can skip ahead to [Step 1](/tutorial/step01-plugin-configuration.md). Keep in mind that cookiecutter generates a slightly different (and more complete) project layout, which is especially helpful if you plan to publish your plugin on PyPI. + +If you're following the manual approach and want a reference implementation to compare against, see the next section. + +### Demo Git repository (optional) + +The demo repository contains the completed plugin project from this tutorial. Each tutorial step builds on the previous one, and the demo repo includes a snapshot of the code at each step as a separate Git branch. + +To clone the demo repository, `cd` to your preferred workspace (your home directory is fine) and run: + +```bash +git clone --branch step00-empty https://github.com/netbox-community/netbox-plugin-demo +``` + +:blue_square: **Note:** Cloning the demo repository is optional, but it makes it easy to jump between steps and compare your work to a known-good implementation if you get stuck. + +This completes our initial setup. + +
+ +[Step 1: Plugin Configuration](/tutorial/step01-plugin-configuration.md) :arrow_right: + +
diff --git a/tutorial/step01-initial-setup.md b/tutorial/step01-initial-setup.md deleted file mode 100644 index cbcf90d..0000000 --- a/tutorial/step01-initial-setup.md +++ /dev/null @@ -1,163 +0,0 @@ -# Step 1: Initial Setup - -Before we can begin work on our plugin, we must first ensure that we have a suitable development environment in place. - -## Set Up the Development Environment - -### Install NetBox - -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/). - -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. - -: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`). - -:warning: **Warning:** This guide requires NetBox v3.2 or later. Attempting to use an earlier NetBox release will not work. - -### Clone the git Repository - -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 `step00-empty` branch, which will provide us with an empty workspace to start. - -```bash -$ git clone --branch step00-empty https://github.com/netbox-community/netbox-plugin-demo -Cloning into 'netbox-plugin-demo'... -remote: Enumerating objects: 58, done. -remote: Counting objects: 100% (58/58), done. -remote: Compressing objects: 100% (42/42), done. -remote: Total 58 (delta 12), reused 58 (delta 12), pack-reused 0 -Unpacking objects: 100% (58/58), done. -``` - -: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. - -## Plugin Configuration - -### Create `__init__.py` - -Our plugin is for managing access lists in NetBox, so we'll give it an appropriate name, such as `netbox_access_lists`. - -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. -The `PluginConfig` class holds all the information Netbox needs to know about our plugin to install it. - -```bash -$ mkdir netbox_access_lists -$ touch netbox_access_lists/__init__.py -``` - -Next, open `__init__.py` in the text editor of your choice and import the `PluginConfig` class from NetBox at the top of the file. - -```python -from netbox.plugins import PluginConfig -``` - -### Create the PluginConfig Class - -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. - -```python -class NetBoxAccessListsConfig(PluginConfig): - name = 'netbox_access_lists' - verbose_name = ' NetBox Access Lists' - description = 'Manage simple ACLs in NetBox' - version = '0.1' - base_url = 'access-lists' -``` - -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: - -```python -config = NetBoxAccessListsConfig -``` - -## Create a README - -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. - -Back in the project's root (one level up from `__init__.py`), create a file named `README.md` and enter the following content: - -```markdown -## netbox-access-lists - -Manage simple access control lists in NetBox -``` - -: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. - -## Install the Plugin - -### Create `setup.py` - -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. - -```python -from setuptools import find_packages, setup - -setup( - name='netbox-access-lists', - version='0.1', - description='An example NetBox plugin', - install_requires=[], - packages=find_packages(), - include_package_data=True, - zip_safe=False, -) -``` - -:warning: **Warning:** Be sure to create `setup.py` in the project root and _not_ within the `netbox_access_lists` directory. - -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. - -: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. - -### Activate the Virtual Environment - -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: - -```bash -$ source /opt/netbox/venv/bin/activate -``` - -### Run `setup.py` - -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. - -```bash -$ python3 setup.py develop -running develop -running egg_info -creating netbox_access_lists.egg-info -writing manifest file 'netbox_access_lists.egg-info/SOURCES.txt' -writing manifest file 'netbox_access_lists.egg-info/SOURCES.txt' -running build_ext -``` - -### Configure NetBox - -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: - -```python -# configuration.py -PLUGINS = [ - 'netbox_access_lists', -] -``` - -Save the file and run the NetBox development server (if not already running): - -```bash -$ python netbox/manage.py runserver -``` - -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 **Admin > System > Plugins** you should see our plugin listed. - -![Django admin UI: Plugins list](/images/step01-django-admin-plugins.png) - -: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. - -This completes our initial setup. Now, onto the fun stuff! - -
- -[Step 2: Models](/tutorial/step02-models.md) :arrow_right: - -
diff --git a/tutorial/step01-plugin-configuration.md b/tutorial/step01-plugin-configuration.md new file mode 100644 index 0000000..841e79d --- /dev/null +++ b/tutorial/step01-plugin-configuration.md @@ -0,0 +1,258 @@ +# Step 1: Plugin Definition + +Make sure you've completed the [initial setup](/tutorial/step00-initial-setup.md) before proceeding. + +Our plugin will provide a simple interface for managing access lists in NetBox. +We'll call it `netbox_access_lists`, and it will live under `/plugins/access-lists/` in the web UI. + +In this step, we'll create the minimal skeleton NetBox needs to recognize our plugin: + +- a Python package (`netbox_access_lists/`) +- a `PluginConfig` definition in `__init__.py` +- basic project metadata (`pyproject.toml`) and docs (`README.md`) +- an editable install into NetBox's virtual environment + +By the end, NetBox should list the plugin under **Admin > System > Plugins**. (It won't do much yet.) + +## Plugin Configuration + +Before we can begin developing our plugin, navigate to the project root directory. + +If you cloned the demo repository, you can directly `cd` into it: + +```bash +cd netbox-plugin-demo +``` + +Otherwise, create a new directory for your plugin's code and then `cd` into it: + +```bash +mkdir -p netbox-plugin-demo +cd netbox-plugin-demo +``` + +:blue_square: **Note:** The name of this directory doesn't matter. Many developers use the distribution name with hyphens (e.g. `netbox-access-lists`). To keep the tutorial consistent (and less confusing), we'll use `netbox-plugin-demo` as the project root directory. + +### Create package root + +Our plugin is for managing access lists in NetBox, so we'll give the Python package an appropriate name, such as `netbox_access_lists`. + +:blue_square: **Note:** It's recommended to prefix the name of your plugin with `netbox_` to reduce the chance of collisions. + +First, create a **directory** to hold our plugin's Python code (i.e., the plugin's package root) named `netbox_access_lists`: + +```bash +mkdir -p netbox_access_lists +``` + +Mind the difference between our *project* root directory and the plugin's *package* root: + +```text +netbox-plugin-demo/ # project root +└── netbox_access_lists/ # package root (Python package) +``` + +:warning: **Warning:** The name of the plugin directory must match the name of the Python package it defines, and it must use **underscores** (not hyphens). + +### Create the `PluginConfig` class + +The `PluginConfig` class holds all the information NetBox needs to load our plugin. + +In the package root, create a new file named `__init__.py`: + +```bash +touch netbox_access_lists/__init__.py +``` + +Open `__init__.py` in the text editor of your choice and import `PluginConfig` at the top of the file: + +```python +from netbox.plugins import PluginConfig +``` + +Next, create a new class named `NetBoxAccessListsConfig` by subclassing `PluginConfig`. +There are [many optional attributes](https://netboxlabs.com/docs/netbox/plugins/development/#pluginconfig-attributes) that can be set here, but for now we only need a few: + +```python +class NetBoxAccessListsConfig(PluginConfig): + name = 'netbox_access_lists' + verbose_name = 'NetBox Access Lists' + description = 'Manage simple access lists in NetBox' + version = '0.1.0' + base_url = 'access-lists' + min_version = '4.5.0' + max_version = '4.5.99' +``` + +:blue_square: **Note:** `min_version` and `max_version` let you declare which NetBox versions your plugin supports. It's common to pin to a NetBox minor release while developing, then widen the range once you've validated compatibility. + +Finally, we need to expose this class as `config` so that NetBox can detect it. +Add this line to the end of the file: + +```python +config = NetBoxAccessListsConfig +``` + +## Create a README + +It's considered best practice to include a `README.md` file with any project you publish. +This is a brief piece of documentation that explains what your project does, how to install/run it, where to find help, etc. + +Because this is just a learning exercise, we have little to say about our plugin, but go ahead and create the file anyway. + +Back in the project root (one level up from `__init__.py`), create a file named `README.md`: + +```bash +touch README.md +``` + +Open the file in your text editor and enter the following: + +```markdown +# NetBox Access Lists + +Manage simple access lists in NetBox. +``` + +:green_circle: **Tip:** The `.md` extension tells tools (including GitHub) to render the file as Markdown for better readability. + +## Install the plugin + +### Create `pyproject.toml` + +To enable installation of our plugin into the virtual environment we created during NetBox installation, we'll create a simple Python package configuration file. + +In the project root directory (where `README.md` is located), create a file named `pyproject.toml` and enter the configuration below. +Ensure that the `name` attribute matches the name of the plugin as it would be listed on PyPI (i.e. `netbox-access-lists`). + +```bash +touch pyproject.toml +``` + +Remember to update your contact information as needed. + +```toml +# See PEP 518 for the spec of this file +# https://www.python.org/dev/peps/pep-0518/ + +[build-system] +requires = ["setuptools >= 77.0.3"] +build-backend = "setuptools.build_meta" + +[project] +# This is the *distribution* name (e.g., what you'd publish on PyPI), so hyphens are typical. +name = "netbox-access-lists" +version = "0.1.0" +requires-python = ">=3.12.0" +authors = [ + {name = "Tutorial Author", email = "your@email.com"}, +] +description = "Manage simple access lists in NetBox" +readme = { file = "README.md", content-type = "text/markdown" } +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Framework :: Django :: 5.2", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +# Include the root package *and* any subpackages we add later (api/, graphql/, etc.). +include = ["netbox_access_lists*"] + +[tool.setuptools.package-data] +netbox_access_lists = [ + "templates/**", + "static/**", + "locale/**/*.mo" +] +``` + +:warning: **Warning:** Be sure to create `pyproject.toml` in the project root and _not_ within the `netbox_access_lists` directory. + +You should now have a file structure similar to the following: + +```text +. +├── netbox_access_lists/ +│ └── __init__.py +├── pyproject.toml +└── README.md +``` + +This file will be used by `pip` to install our plugin into the virtual environment. +There are many other options that can be configured here, but for now we'll stick with the basics. + +:green_circle: **Tip:** There are alternative methods for installing Python code that work just as well; feel free to use your preferred approach. Just be aware that this guide assumes the use of `pip` and adjust accordingly. + +### Activate the virtual environment + +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. + +If you used the documentation defaults, the venv path will be `/opt/netbox/venv/`: + +```bash +source /opt/netbox/venv/bin/activate +``` + +### Run `pip install` + +We can now install our plugin by running `pip install -e .` from the project root. + +The `-e` (`--editable`) argument tells `pip` to create a link to our local development directory instead of copying files into the virtual environment. This avoids the need to re-install the plugin every time we make a change. + +```bash +python -m pip install -e . +``` + +## Configure NetBox + +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 (it 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: + +```python +# configuration.py +PLUGINS = [ + 'netbox_access_lists', +] +``` + +:blue_square: **Note:** This uses the **Python package name** (underscores), not the distribution name from `pyproject.toml` (hyphens). + +Save the file and start (or restart) the NetBox development server: + +```bash +python netbox/manage.py runserver +``` + +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 **Admin > System > Plugins** you should see our plugin listed. + +![NetBox UI: Plugins list](/images/step01-netbox-plugin-list.png) + +:green_circle: **Tip:** If you cloned the demo repository, each tutorial step has a corresponding branch you can diff against. For this step, run: +```bash +git diff remotes/origin/step01-plugin-config +``` + +This completes our plugin definition setup. Now, onto the fun stuff! + +
+ +:arrow_left: [Step 0: Initial Setup](/tutorial/step00-initial-setup.md) | [Step 2: Models](/tutorial/step02-models.md) :arrow_right: + +
diff --git a/tutorial/step02-models.md b/tutorial/step02-models.md index 2968e8d..298a7eb 100644 --- a/tutorial/step02-models.md +++ b/tutorial/step02-models.md @@ -1,19 +1,30 @@ # Step 2: Models -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. +In this step, we will define some Django models to hold our plugin's data. -:blue_square: **Note:** If you skipped the previous step, run `git checkout step01-initial-setup`. +A model is a Python class that represents a table in the PostgreSQL database. +Each instance of a model class maps to a single row in that table. +We use models instead of raw SQL because working with Python objects is usually simpler, safer, and easier to evolve over time. + +:blue_square: **Note:** If you skipped the previous step, and you cloned the `netbox-plugin-demo` repository, you can catch up by running `git checkout step01-plugin-configuration`. ## Create the Models -First, `cd` into the `netbox_access_lists` directory and create a file named `models.py`. This is where our model classes will be defined. +First, change into the `netbox_access_lists` directory and create a file named `models.py`. +This is where our model classes will live. ```bash -$ cd netbox_access_lists -$ edit models.py +cd netbox_access_lists +touch models.py ``` -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. +Open `models.py` in your editor and add the imports below. + +We import: + +* Django's `models` library for defining fields +* NetBox's `NetBoxModel` base class so our models get NetBox features like custom fields, tags, and change logging +* PostgreSQL's `ArrayField`, which we will use to store a list of ports on a rule ```python from django.contrib.postgres.fields import ArrayField @@ -21,89 +32,130 @@ from django.db import models from netbox.models import NetBoxModel ``` -We'll create two models: +We will create two models: -* `AccessList`: This will represent an access list, with a name and one or more rules assigned to it. -* `AccessListRule`: This will be an individual rule with source/destination IP addresses, port numbers, etc. assigned to an access list. +* `AccessList` represents an access list, with a name and one or more rules +* `AccessListRule` represents a single rule, including source and destination prefixes and ports, action, and optional description ### AccessList -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. +We'll need to define a few fields for our model. +A field is a piece of data associated with an instance of a model and is defined using a class attribute. +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. ```python class AccessList(NetBoxModel): name = models.CharField( - max_length=100 + max_length=100, ) default_action = models.CharField( - max_length=30 + max_length=30, ) comments = models.TextField( - blank=True + blank=True, ) ``` -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.) +By default, Django orders records by the primary key. +For access lists, ordering by name is usually more natural. +We can do that by adding a `Meta` class inside `AccessList`. + +We will also set `verbose_name`, which controls how the model name is displayed in the UI. ```python class Meta: ordering = ('name',) + verbose_name = 'Access List' ``` -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.) +Finally, add a `__str__()` method so the model has a friendly string representation. ```python def __str__(self): return self.name ``` +At this point, `models.py` should look like this: + +```python +from django.contrib.postgres.fields import ArrayField +from django.db import models +from netbox.models import NetBoxModel + + +class AccessList(NetBoxModel): + name = models.CharField( + max_length=100, + ) + default_action = models.CharField( + max_length=30, + ) + comments = models.TextField( + blank=True, + ) + + class Meta: + ordering = ('name',) + verbose_name = 'Access List' + + def __str__(self): + return self.name +``` + ### AccessListRule -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: +Next, we will define a model for the individual rules that belong to an access list. + +Each rule needs the following fields: * Parent access list (a foreign key to an `AccessList` instance) -* Index (the rule's order in the list) +* Index (rule order) * Protocol * Source prefix -* Source port(s) +* Source ports * Destination prefix -* Destination port(s) -* Action (permit, allow, or reject) +* Destination ports +* Action (permit, deny, reject) * Description (optional) -Let's start by defining a `ForeignKey` field pointing to the `AccessList` model. +Start by creating the model and adding a `ForeignKey` back to `AccessList`: ```python class AccessListRule(NetBoxModel): access_list = models.ForeignKey( - to=AccessList, + to='netbox_access_lists.AccessList', on_delete=models.CASCADE, - related_name='rules' + related_name='rules', ) ``` -We're passing three keyword arguments to the field: +A quick breakdown of the arguments we passed: -* `to` references the related model class -* `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. -* `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()`. +* `to` identifies the related model. Using the string form `'.'` avoids import order issues and matches how we reference models in other apps. +* `on_delete=models.CASCADE` means: if an access list is deleted, delete its rules as well. +* `related_name='rules'` creates a reverse relationship so you can access rules from an access list instance via `acl.rules.all()`. -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. +Next, add an `index` field to store the rule's position within the access list. +We'll use `PositiveIntegerField` because only positive numbers are supported: ```python index = models.PositiveIntegerField() ``` -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. +Now add the protocol field. +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. ```python protocol = models.CharField( max_length=30, - blank=True + blank=True, ) ``` -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](https://netboxlabs.com/docs/netbox/en/stable/models/ipam/prefix/) 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`. +Next, define a source prefix. +We reference NetBox's [`Prefix` model](https://netboxlabs.com/docs/netbox/en/stable/models/ipam/prefix/) using the `'.'` format, in this case `ipam.Prefix`. +We also mark it optional using `blank=True` and `null=True`. ```python source_prefix = models.ForeignKey( @@ -111,25 +163,27 @@ Next we need to define a source prefix. We're going to use a foreign key field t on_delete=models.PROTECT, related_name='+', blank=True, - null=True + null=True, ) ``` -:green_circle: **Tip:** Whereas `CASCADE` automatically deletes child objects, `PROTECT` prevents the deletion of the parent option if any child objects exist. +:green_circle: **Tip:** `PROTECT` prevents deletion of the referenced object while it is still in use. This is often safer than allowing accidental deletes of shared objects like prefixes. -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. +Notice `related_name='+'` above. +This tells Django not to create a reverse relationship from `Prefix` back to `AccessListRule`, which keeps the `Prefix` model namespace cleaner. -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`](https://docs.djangoproject.com/en/stable/ref/contrib/postgres/fields/#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. +Now add a field for source ports. +We want to support more than one port per rule, so we will store a list of integers using [`ArrayField`](https://docs.djangoproject.com/en/stable/ref/contrib/postgres/fields/#arrayfield) to store a list of `PositiveIntegerField` values. ```python source_ports = ArrayField( base_field=models.PositiveIntegerField(), blank=True, - null=True + null=True, ) ``` -Let's go ahead an add destination prefix and port fields as well. These are essentially duplicates of our source fields. +Now add destination prefix and destination ports, which mirror the source fields: ```python destination_prefix = models.ForeignKey( @@ -137,33 +191,38 @@ Let's go ahead an add destination prefix and port fields as well. These are esse on_delete=models.PROTECT, related_name='+', blank=True, - null=True + null=True, ) destination_ports = ArrayField( base_field=models.PositiveIntegerField(), blank=True, - null=True + null=True, ) ``` -Finally, we'll add fields for the rule's action and description. The action is required but a description is not. +Finally, add the rule action (required) and an optional description: ```python action = models.CharField( - max_length=30 + max_length=30, ) description = models.CharField( max_length=500, - blank=True + blank=True, ) ``` -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. +Add a `Meta` class to: + +* order rules by access list and index +* enforce uniqueness so two rules in the same access list cannot share the same index +* set a friendly model name for the UI ```python class Meta: ordering = ('access_list', 'index') unique_together = ('access_list', 'index') + verbose_name = 'Access List Rule' ``` Finally, we'll add a `__str__()` method to display the parent access list and index number when rendering an `AccessListRule` instance as a string: @@ -175,83 +234,104 @@ Finally, we'll add a `__str__()` method to display the parent access list and in ## Define Field Choices -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: +Some of our fields should only allow specific values. +For example, we want an `action` to be one of: * Permit * Deny * Reject -We can define a [`ChoiceSet`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/models/#choice-sets) 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: +NetBox provides [`ChoiceSet`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/models/#choice-sets) to define a reusable list of valid values (with optional colors for the UI). + +Create a new file named `choices.py` next to `models.py`: + +```bash +touch choices.py +``` + +In `choices.py`, import `ChoiceSet`: ```python from utilities.choices import ChoiceSet ``` -Then, below the import statements but above the model definitions, create a child class named `ActionChoices`: +Now define `ActionChoices`: ```python class ActionChoices(ChoiceSet): - key = 'AccessListRule.action' + PERMIT = 'permit' + DENY = 'deny' + REJECT = 'reject' CHOICES = [ - ('permit', 'Permit', 'green'), - ('deny', 'Deny', 'red'), - ('reject', 'Reject (Reset)', 'orange'), + (PERMIT, 'Permit', 'green'), + (DENY, 'Deny', 'red'), + (REJECT, 'Reject (Reset)', 'orange'), ] ``` The `CHOICES` attribute must be an iterable of two- or three-value tuples, each of which defines the following: -* The raw value to be stored in the database -* A human-friendly string for display -* A color for display in the UI (optional, see [available colors](https://netboxlabs.com/docs/netbox/en/stable/configuration/data-validation/#field_choices)) +* the database value (e.g. `permit` or `deny`) +* the UI label (e.g. Permit, Deny, Reject) +* an optional UI color (see [available colors](https://netboxlabs.com/docs/netbox/en/stable/configuration/data-validation/#field_choices)) -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://netboxlabs.com/docs/netbox/en/stable/configuration/data-validation/#field_choices) configuration parameter. - -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. +Now create `ProtocolChoices` as well: ```python - # AccessList - default_action = models.CharField( - max_length=30, - choices=ActionChoices - ) +class ProtocolChoices(ChoiceSet): + key = 'AccessListRule.protocol' - # AccessListRule - action = models.CharField( - max_length=30, - choices=ActionChoices - ) + TCP = 'tcp' + UDP = 'udp' + ICMP = 'icmp' + + CHOICES = [ + (TCP, 'TCP', 'blue'), + (UDP, 'UDP', 'orange'), + (ICMP, 'ICMP', 'purple'), + ] ``` -Let's create a set of choices for a rule's `protocol` field as well. Add this below the `ActionChoices` class: +:blue_square: **Note:** We set `key` so an administrator can replace or extend these choices via NetBox's [`FIELD_CHOICES`](https://netboxlabs.com/docs/netbox/en/stable/configuration/data-validation/#field_choices) setting. -```python -class ProtocolChoices(ChoiceSet): +### Apply Choices in the Models - CHOICES = [ - ('tcp', 'TCP', 'blue'), - ('udp', 'UDP', 'orange'), - ('icmp', 'ICMP', 'purple'), - ] +Back in `models.py`, import the choice sets: + +```python +from .choices import ActionChoices, ProtocolChoices ``` -Then, add the `choices` keyword argument to the `protocol` field: +Then update these fields to use the appropriate choices: ```python + # AccessList + default_action = models.CharField( + max_length=30, + choices=ActionChoices, + ) + # AccessListRule protocol = models.CharField( max_length=30, choices=ProtocolChoices, - blank=True + blank=True, + ) + # ... + action = models.CharField( + max_length=30, + choices=ActionChoices, ) ``` ### Add Choice Color Methods -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. +NetBox can display colors for choice values in tables and templates. +To support that, we add a method per choice field that returns the selected color. +This works similar to Django's `get_FOO_display()` methods, but returns a color (defined on the field's `ChoiceSet`) rather than a label. -Let's add a `get_default_action_color()` method on `AccessList`: +Add this method to `AccessList`: ```python class AccessList(NetBoxModel): @@ -260,7 +340,7 @@ class AccessList(NetBoxModel): return ActionChoices.colors.get(self.default_action) ``` -We also need to add methods for `protocol` and `action` on `AccessListRule`: +Add these methods to `AccessListRule`: ```python class AccessListRule(NetBoxModel): @@ -272,54 +352,162 @@ class AccessListRule(NetBoxModel): return ActionChoices.colors.get(self.action) ``` +At this point, your `models.py` file should look like this: + +```python +from django.contrib.postgres.fields import ArrayField +from django.db import models +from netbox.models import NetBoxModel + +from .choices import ActionChoices, ProtocolChoices + + +class AccessList(NetBoxModel): + name = models.CharField( + max_length=100, + ) + default_action = models.CharField( + max_length=30, + choices=ActionChoices, + ) + comments = models.TextField( + blank=True, + ) + + class Meta: + ordering = ('name',) + verbose_name = 'Access List' + + def __str__(self): + return self.name + + def get_default_action_color(self): + return ActionChoices.colors.get(self.default_action) + + +class AccessListRule(NetBoxModel): + access_list = models.ForeignKey( + to='netbox_access_lists.AccessList', + on_delete=models.CASCADE, + related_name='rules', + ) + index = models.PositiveIntegerField() + protocol = models.CharField( + max_length=30, + choices=ProtocolChoices, + blank=True, + ) + source_prefix = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True, + ) + source_ports = ArrayField( + base_field=models.PositiveIntegerField(), + blank=True, + null=True, + ) + destination_prefix = models.ForeignKey( + to='ipam.Prefix', + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True, + ) + destination_ports = ArrayField( + base_field=models.PositiveIntegerField(), + blank=True, + null=True, + ) + action = models.CharField( + max_length=30, + choices=ActionChoices, + ) + description = models.CharField( + max_length=500, + blank=True, + ) + + class Meta: + ordering = ('access_list', 'index') + unique_together = ('access_list', 'index') + verbose_name = 'Access List Rule' + + def __str__(self): + return f'{self.access_list}: Rule {self.index}' + + def get_protocol_color(self): + return ProtocolChoices.colors.get(self.protocol) + + def get_action_color(self): + return ActionChoices.colors.get(self.action) +``` + ## Create Schema Migrations -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/stable/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. +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/stable/topics/migrations/). +This will inspect our model classes and generate the necessary migration files automatically. + +This is a two-step process: -: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. +1. Generate migration files with `makemigrations` +2. Apply them to the database with `migrate` + +:warning: **Warning:** Before continuing, confirm that `DEVELOPER=True` is set in NetBox's `configuration.py`. NetBox uses this setting to guard against accidental migration creation in non development environments. ### Generate Migration Files -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. +Change into the NetBox installation root so you can run `manage.py` (for example `/opt/netbox`). + +First, run `makemigrations` in dry run mode so you can review what will be generated: ```bash -$ python netbox/manage.py makemigrations netbox_access_lists --dry-run +(venv) $ python netbox/manage.py makemigrations netbox_access_lists --dry-run Migrations for 'netbox_access_lists': ~/netbox-plugin-demo/netbox_access_lists/migrations/0001_initial.py - - Create model AccessList - - Create model AccessListRule + + Create model AccessList + + Create model AccessListRule ``` -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): +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, run it again without `--dry-run` to actually create the migration file: ```bash -$ python netbox/manage.py makemigrations netbox_access_lists +(venv) $ python netbox/manage.py makemigrations netbox_access_lists Migrations for 'netbox_access_lists': ~/netbox-plugin-demo/netbox_access_lists/migrations/0001_initial.py - - Create model AccessList - - Create model AccessListRule + + Create model AccessList + + Create model AccessListRule ``` -Back in your plugin workspace, you should now see a `migrations` directory with two files: `__init__.py` and `0001_initial.py`. +Back in your plugin workspace, you should now see a `migrations` directory with `__init__.py` and `0001_initial.py`: ```bash $ tree . -├── __init__.py -├── migrations -│   ├── 0001_initial.py -│   ├── __init__.py -... +├── netbox_access_lists +│ ├── choices.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ └── models.py +├── pyproject.toml +└── README.md ``` ### Apply Migrations -Finally, we can apply the migration file using the `migrate` management command: +Now apply the migrations from the NetBox root directory: ```bash -$ python netbox/manage.py migrate +(venv) $ python netbox/manage.py migrate netbox_access_lists Operations to perform: - Apply all migrations: admin, auth, circuits, contenttypes, dcim, django_rq, extras, ipam, netbox_access_lists, sessions, social_django, taggit, tenancy, users, virtualization, wireless + Apply all migrations: netbox_access_lists Running migrations: Applying netbox_access_lists.0001_initial... OK ``` @@ -327,22 +515,22 @@ Running migrations: If you're curious, you can inspect the newly created database tables, using the `dbshell` management command to enter a PostgreSQL shell: ```bash -$ python netbox/manage.py dbshell -psql (10.19 (Ubuntu 10.19-0ubuntu0.18.04.1)) -SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) +(venv) $ python netbox/manage.py dbshell +psql (16.11 (Ubuntu 16.11-0ubuntu0.24.04.1)) +SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off) Type "help" for help. netbox=> \d netbox_access_lists_accesslist - Table "public.netbox_access_lists_accesslist" - Column | Type | Collation | Nullable | Default --------------------+--------------------------+-----------+----------+------------------------------------------------------------ - id | bigint | | not null | nextval('netbox_access_lists_accesslist_id_seq'::regclass) - created | timestamp with time zone | | | - last_updated | timestamp with time zone | | | - custom_field_data | jsonb | | not null | - name | character varying(100) | | not null | - default_action | character varying(30) | | not null | - comments | text | | not null | + Table "public.netbox_access_lists_accesslist" + Column | Type | Collation | Nullable | Default +-------------------+--------------------------+-----------+----------+---------------------------------- + id | bigint | | not null | generated by default as identity + created | timestamp with time zone | | | + last_updated | timestamp with time zone | | | + custom_field_data | jsonb | | not null | + name | character varying(100) | | not null | + default_action | character varying(30) | | not null | + comments | text | | not null | Indexes: "netbox_access_lists_accesslist_pkey" PRIMARY KEY, btree (id) Referenced by: @@ -353,65 +541,93 @@ Type `\q` to exit `dbshell`. ## Create Some Objects -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. +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 that allows us to interact directly with NetBox objects and other resources. + +Start the NetBox interactive shell: ```bash -$ python netbox/manage.py nbshell -from netbox### NetBox interactive shell -### Python 3.8.12 | Django 4.0.3 | NetBox 3.2.0 -### lsmodels() will show available models. Use help() for more info. +(venv) $ python netbox/manage.py nbshell +### NetBox interactive shell +### Python v3.12.3 | Django v5.2.10 | NetBox Community v4.5.2 +### Plugins: netbox_access_lists v0.1 +### lsapps() & lsmodels() will show available models. Use help() for more info. >>> ``` -Let's create and save an access list: +Create an access list, validate it with `full_clean()`, then save it: ```python ->>> from netbox_access_lists.models import * ->>> acl = AccessList(name='MyACL1', default_action='deny') +>>> from netbox_access_lists.models import AccessList, AccessListRule +>>> from netbox_access_lists.choices import ActionChoices, ProtocolChoices +>>> acl = AccessList(name='MyACL1', default_action=ActionChoices.DENY) +>>> acl.full_clean() +>>> acl.save() >>> acl ->>> acl.save() ``` -Next we'll create some prefixes to reference in rules: +Next, create a couple of prefixes to reference in rules: ```python +>>> from ipam.models import Prefix >>> prefix1 = Prefix(prefix='192.168.1.0/24') +>>> prefix1.full_clean() >>> prefix1.save() >>> prefix2 = Prefix(prefix='192.168.2.0/24') +>>> prefix2.full_clean() >>> prefix2.save() ``` -And finally we'll create a couple rules for our access list: +:blue_square: **Note:** If `full_clean()` complains about missing required fields (for example `status`), set the missing values and try again. Defaults can vary depending on your NetBox version and configuration. + +Now create two rules: ```python ->>> AccessListRule( +>>> rule1 = AccessListRule( ... access_list=acl, ... index=10, -... protocol='tcp', +... protocol=ProtocolChoices.TCP, ... destination_prefix=prefix1, ... destination_ports=[80, 443], -... action='permit', +... action=ActionChoices.PERMIT, ... description='Web traffic' -... ).save() ->>> AccessListRule( +... ) +>>> rule1.full_clean() +>>> rule1.save() +>>> rule2 = AccessListRule( ... access_list=acl, ... index=20, -... protocol='udp', +... protocol=ProtocolChoices.UDP, ... destination_prefix=prefix2, ... destination_ports=[53], -... action='permit', +... action=ActionChoices.PERMIT, ... description='DNS' -... ).save() +... ) +>>> rule2.full_clean() +>>> rule2.save() +``` + +Verify the rules are associated with the access list. +We can use the `all()` manager to retrieve all rules belonging to a particular access list: + +```python >>> acl.rules.all() , ]> ``` -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. +Exit the shell: + +```python +>>> exit() +``` + +Excellent. We can now create access lists and rules in the database. +In the next steps, we will expose this functionality in the NetBox user interface.
-:arrow_left: [Step 1: Initial Setup](/tutorial/step01-initial-setup.md) | [Step 3: Tables](/tutorial/step03-tables.md) :arrow_right: +:arrow_left: [Step 1: Plugin Configuration](/tutorial/step01-plugin-configuration.md) | [Step 3: Tables](/tutorial/step03-tables.md) :arrow_right:
- diff --git a/tutorial/step03-tables.md b/tutorial/step03-tables.md index 698dbc0..6ecbfcd 100644 --- a/tutorial/step03-tables.md +++ b/tutorial/step03-tables.md @@ -1,109 +1,282 @@ # Step 3: Tables -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. +You are probably already familiar with object lists in NetBox. +These pages show all instances of a given model, such as Sites or Devices, in a single place. +NetBox builds these pages using table classes powered by the [`django-tables2`](https://django-tables2.readthedocs.io/) library. -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. +We could write raw HTML `
` markup in every template, but that gets repetitive fast and is harder to maintain. +Using table classes also gives us sorting, pagination, and consistent styling with very little extra effort. -:blue_square: **Note:** If you skipped the previous step, run `git checkout step02-models`. +:blue_square: **Note:** If you skipped the previous step, run `git checkout step02-models` (if you cloned the repository `netbox-plugin-demo`). ## Create the Tables -We'll create two tables, one for each of our models. Begin by creating `tables.py` in the `netbox_access_lists/` directory. +We will create two tables, one for each of our models. + +Start by creating `tables.py` inside the `netbox_access_lists/` directory: ```bash -$ cd netbox_access_lists/ -$ edit tables.py +cd netbox_access_lists/ +touch tables.py ``` -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`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/tables/#netboxtable) class, which will serve as the base class for our tables, and `ChoiceFieldColumn`. Finally we import our plugin's models from `models.py`. +Open `tables.py` and add the imports below. + +We import: + +* `django_tables2` for basic column definitions +* NetBox's [`NetBoxTable`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/tables/#netboxtable) base class so we automatically get common NetBox columns and styling +* NetBox's `ChoiceFieldColumn` and `columns` helpers for choice fields and array fields +* Our plugin models so the tables know what they are rendering ```python import django_tables2 as tables +from netbox.tables import ChoiceFieldColumn, columns, NetBoxTable -from netbox.tables import NetBoxTable, ChoiceFieldColumn from .models import AccessList, AccessListRule ``` +For reference, your plugin workspace should now look like this: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ └── tables.py +├── pyproject.toml +└── README.md +``` + ### AccessListTable -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. +Create `AccessListTable` as a subclass of `NetBoxTable`. +Inside it, add a `Meta` class that inherits from `NetBoxTable.Meta`. +This is where we declare the model, available fields, and which columns are shown by default. + +Start with this skeleton: ```python class AccessListTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = AccessList - fields = ('pk', 'id', 'name', 'default_action', 'comments', 'actions') - default_columns = ('name', 'default_action') + fields = ( + 'pk', + 'id', + 'name', + 'default_action', + 'comments', + 'actions', + ) + default_columns = ('name', 'default_action', 'actions') ``` -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. +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. + +A few quick notes about these columns: -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. +* `pk` and `actions` are provided by `NetBoxTable`. They render the row selection checkbox and the actions' dropdown. +* `id` is the numeric primary key. NetBox commonly includes it, but it is usually not shown by default. +* `name`, `default_action`, and `comments` come from the fields on the `AccessList` model. -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`. +This will already work, but we can make it nicer. + +First, make the `name` column clickable, so each row links to the object view: ```python class AccessListTable(NetBoxTable): name = tables.Column( - linkify=True + linkify=True, ) ``` -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. +Next, `default_action` is a choice field with a color. +Use `ChoiceFieldColumn` so NetBox can render the label and its color consistently: ```python default_action = ChoiceFieldColumn() ``` -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: +Finally, add a small quality of life column that shows how many rules each access list has. +We will call it `rule_count`. +The list view will populate this using a queryset annotation in a later step. + +Add the column, then include it in `fields` and `default_columns`: ```python class AccessListTable(NetBoxTable): name = tables.Column( - linkify=True + linkify=True, ) default_action = ChoiceFieldColumn() - rule_count = tables.Column() + rule_count = tables.Column( + verbose_name='Rules', + ) class Meta(NetBoxTable.Meta): model = AccessList - fields = ('pk', 'id', 'name', 'rule_count', 'default_action', 'comments', 'actions') - default_columns = ('name', 'rule_count', 'default_action') + fields = ( + 'pk', + 'id', + 'name', + 'default_action', + 'rule_count', + 'comments', + 'actions', + ) + default_columns = ('name', 'default_action', 'rule_count', 'actions') ``` -Once our plugin is finished, the table will look like this: +Once the plugin is wired up, the table will look like this: -![Access lists table](../images/step05-accesslist-list.png) +![Access lists table](../images/step06-accesslist-list.png) ### AccessListRuleTable -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. +Now we will create a table for `AccessListRule` using the same pattern. + +We will: + +* Linkify `access_list`, `index`, `source_prefix`, and `destination_prefix` +* Render `source_ports` and `destination_ports` using `ArrayColumn` +* Render `protocol` and `action` using `ChoiceFieldColumn` so their labels and colors are displayed correctly + +```python +class AccessListRuleTable(NetBoxTable): + access_list = tables.Column( + linkify=True, + ) + index = tables.Column( + linkify=True, + ) + source_prefix = tables.Column( + linkify=True, + ) + source_ports = columns.ArrayColumn() + destination_prefix = tables.Column( + linkify=True, + ) + destination_ports = columns.ArrayColumn() + protocol = ChoiceFieldColumn() + action = ChoiceFieldColumn() + + class Meta(NetBoxTable.Meta): + model = AccessListRule + fields = ( + 'pk', + 'id', + 'access_list', + 'index', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'description', + 'actions', + ) + default_columns = ( + 'access_list', + 'index', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'actions', + ) +``` + +### Full `tables.py` + +Your complete `tables.py` file should now look like this: ```python +import django_tables2 as tables +from netbox.tables import ChoiceFieldColumn, columns, NetBoxTable + +from .models import AccessList, AccessListRule + + +class AccessListTable(NetBoxTable): + name = tables.Column( + linkify=True, + ) + default_action = ChoiceFieldColumn() + rule_count = tables.Column( + verbose_name='Rules', + ) + + class Meta(NetBoxTable.Meta): + model = AccessList + fields = ( + 'pk', + 'id', + 'name', + 'default_action', + 'rule_count', + 'comments', + 'actions', + ) + default_columns = ('name', 'default_action', 'rule_count', 'actions') + + class AccessListRuleTable(NetBoxTable): access_list = tables.Column( - linkify=True + linkify=True, ) index = tables.Column( - linkify=True + linkify=True, + ) + source_prefix = tables.Column( + linkify=True, + ) + source_ports = columns.ArrayColumn() + destination_prefix = tables.Column( + linkify=True, ) + destination_ports = columns.ArrayColumn() protocol = ChoiceFieldColumn() action = ChoiceFieldColumn() class Meta(NetBoxTable.Meta): model = AccessListRule fields = ( - 'pk', 'id', 'access_list', 'index', 'source_prefix', 'source_ports', 'destination_prefix', - 'destination_ports', 'protocol', 'action', 'description', 'actions', + 'pk', + 'id', + 'access_list', + 'index', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'description', + 'actions', ) default_columns = ( - 'access_list', 'index', 'source_prefix', 'source_ports', 'destination_prefix', - 'destination_ports', 'protocol', 'action', 'actions', + 'access_list', + 'index', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'actions', ) ``` -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. +That is all we need for listing these objects. +Next, we will define forms so users can create and edit access lists and rules in the UI.
diff --git a/tutorial/step04-forms.md b/tutorial/step04-forms.md index 0e5f4c5..05a414c 100644 --- a/tutorial/step04-forms.md +++ b/tutorial/step04-forms.md @@ -1,38 +1,67 @@ # Step 4: Forms -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. +Form classes generate HTML form elements for the user interface, and they also process and validate user input. +In NetBox, forms are used primarily to create, modify, and delete objects. +In this step, we will create one form class for each of our plugin models. -:blue_square: **Note:** If you skipped the previous step, run `git checkout step03-tables`. +:blue_square: **Note:** If you skipped the previous step, run `git checkout step03-tables` (in case you've cloned the repository `netbox-plugin-demo`). ## Create the Forms -Begin by creating a file named `forms.py` in the `netbox_access_lists/` directory. +Begin by creating a file named `forms.py` in the `netbox_access_lists/` directory of your plugin project. ```bash -$ cd netbox_access_lists/ -$ edit forms.py +cd netbox_access_lists/ +touch forms.py ``` -At the top of the file, we'll import NetBox's [`NetBoxModelForm`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#netboxmodelform) class, which will serve as the base class for our forms. We'll also import our plugin's models. +At the top of the file, import NetBox's [`NetBoxModelForm`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#netboxmodelform) class, which will serve as the base class for our forms. +We will also import our plugin models. ```python from netbox.forms import NetBoxModelForm + from .models import AccessList, AccessListRule ``` -### AccessListForm +For reference, your plugin project should now look like this: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ └── tables.py +├── pyproject.toml +└── README.md +``` + +## AccessListForm -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. +Create a class named `AccessListForm` that subclasses `NetBoxModelForm`. +Under it, define a `Meta` subclass specifying the model and fields. + +Notice that `tags` is included even though we did not define it on the model. +That is because `NetBoxModel` provides tag support automatically. ```python class AccessListForm(NetBoxModelForm): class Meta: model = AccessList - fields = ('name', 'default_action', 'comments', 'tags') + fields = ('name', 'default_action', 'comments', 'tags',) ``` -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`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#utilities.forms.fields.fields.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: +This is enough to get a working form, but we can make one small improvement. +Instead of letting Django generate a basic field for `comments`, we can use NetBox's [`CommentField`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#utilities.forms.fields.fields.CommentField). +It applies NetBox friendly defaults like help text and layout. + +To do this, import `CommentField` and override the `comments` field on the form: ```python from utilities.forms.fields import CommentField @@ -45,56 +74,131 @@ class AccessListForm(NetBoxModelForm): fields = ('name', 'default_action', 'comments', 'tags') ``` -Once our plugin is finished, the form will look like this: +Once our plugin is finished, the form will render similar to this: -![Access lists form](../images/step05-accesslist-form.png) +![Access lists form](../images/step06-accesslist-form.png) -### AccessListRuleForm +## AccessListRuleForm -We'll create a form for `AccessListRule` following the same pattern. +Next, we will create a form for `AccessListRule` using the same pattern: ```python class AccessListRuleForm(NetBoxModelForm): - class Meta: model = AccessListRule fields = ( - 'access_list', 'index', 'description', 'source_prefix', 'source_ports', 'destination_prefix', - 'destination_ports', 'protocol', 'action', 'tags', + 'access_list', + 'index', + 'description', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'tags', ) ``` -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. +By default, Django renders foreign keys as a static dropdown that includes every available object. +That works fine for small datasets, but it becomes painful when your NetBox instance has lots of objects. -To avoid this, NetBox provides the [`DynamicModelChoiceField`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#dynamic-object-fields) 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. +To avoid that, NetBox provides [`DynamicModelChoiceField`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#dynamic-object-fields), which uses a dynamic search widget backed by the NetBox REST API. +This keeps the form fast and makes it easier for users to find what they need. :green_circle: **Tip:** The `DynamicModelMultipleChoiceField` class is also available for many-to-many fields, which support the assignment of multiple objects. -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: +We will use `DynamicModelChoiceField` for the three foreign key fields in this form: + +* `access_list` +* `source_prefix` +* `destination_prefix` + +First, update the imports at the top of `forms.py`. +`AccessList` is already imported, but we also need `Prefix` from the `ipam` app. ```python from ipam.models import Prefix from netbox.forms import NetBoxModelForm from utilities.forms.fields import CommentField, DynamicModelChoiceField + from .models import AccessList, AccessListRule ``` -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.) +Now override the relevant fields on `AccessListRuleForm`, using an appropriate queryset for each. +Since `source_prefix` and `destination_prefix` are optional on the model, we set `required=False` to match that behavior. +(Be sure to keep in place the `Meta` class we already defined.) + +```python +class AccessListRuleForm(NetBoxModelForm): + access_list = DynamicModelChoiceField( + queryset=AccessList.objects.all(), + ) + source_prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + ) + destination_prefix = DynamicModelChoiceField( + queryset=Prefix.objects.all(), + required=False, + ) + comments = CommentField() + + class Meta: + # ... +``` + +## Full `forms.py` + +Your full `forms.py` file should now look like this: ```python +from ipam.models import Prefix +from netbox.forms import NetBoxModelForm +from utilities.forms.fields import CommentField, DynamicModelChoiceField + +from .models import AccessList, AccessListRule + + +class AccessListForm(NetBoxModelForm): + comments = CommentField() + + class Meta: + model = AccessList + fields = ('name', 'default_action', 'comments', 'tags') + + class AccessListRuleForm(NetBoxModelForm): access_list = DynamicModelChoiceField( - queryset=AccessList.objects.all() + queryset=AccessList.objects.all(), ) source_prefix = DynamicModelChoiceField( - queryset=Prefix.objects.all() + queryset=Prefix.objects.all(), + required=False, ) destination_prefix = DynamicModelChoiceField( - queryset=Prefix.objects.all() + queryset=Prefix.objects.all(), + required=False, ) + comments = CommentField() + + class Meta: + model = AccessListRule + fields = ( + 'access_list', + 'index', + 'description', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'protocol', + 'action', + 'tags', + ) ``` -With our models, tables, and forms all in place, next we'll create some views to bring everything together! +With our models, tables, and forms in place, we are ready to create views to bring everything together.
diff --git a/tutorial/step05-views.md b/tutorial/step05-views.md index 9fb3f31..c798c60 100644 --- a/tutorial/step05-views.md +++ b/tutorial/step05-views.md @@ -1,258 +1,190 @@ # Step 5: Views -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). +Views are responsible for the business logic in your application. +In practice, that usually means: -Django provides a set of [generic view classes](https://docs.djangoproject.com/en/stable/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. +* receiving an incoming request +* fetching or updating data as needed +* returning a response to the client -In this step, we'll create a set of views for each of our plugin's models. +Each view is typically associated with a URL, and it can handle one or more HTTP request methods (for example `GET` and `POST`). -:blue_square: **Note:** If you skipped the previous step, run `git checkout step04-forms`. +Django includes a set of [generic view classes](https://docs.djangoproject.com/en/stable/topics/class-based-views/generic-display/) that take care of a lot of common request handling. +NetBox builds on that and provides its own view classes for common object workflows like viewing, listing, editing, and deleting. +These also integrate NetBox features such as custom fields and change logging. + +In this step, we will create a set of views for each of our plugin models using the NetBox provided `register_model_view()` decorator. + +:blue_square: **Note:** If you skipped the previous step, run `git checkout step04-forms` (in case you've cloned the repository `netbox-plugin-demo`). ## Create the Views -Begin by creating `views.py` in the `netbox_access_lists/` directory. +Begin by creating `views.py` in the `netbox_access_lists/` directory of your plugin project root. ```bash -$ cd netbox_access_lists/ -$ edit views.py +cd netbox_access_lists/ +touch views.py ``` -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](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/views/#view-classes) module, as it provides the base classes for our views. +Open `views.py` and add the imports below. + +We need: + +* [NetBox's generic views](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/views/#view-classes) base classes +* our plugin modules for forms, models, and tables +* Django's `Count` so we can annotate rule counts for the AccessList table ```python +from django.db.models import Count from netbox.views import generic +from utilities.views import register_model_view + from . import forms, models, tables ``` -: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. +:green_circle: **Tip:** We are importing the full `forms`, `models`, and `tables` modules. If you prefer importing specific classes instead, that is totally fine. Update the view definitions accordingly. + +For each model, we will create four views: + +* **Detail view**: display a single object +* **List view**: display a table of all existing instances of a particular model +* **Edit view**: handle adding and modifying objects +* **Delete view**: handle the deletion of an object + +For reference, your plugin project should now include `views.py`: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── tables.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +## About `register_model_view` -For each model, we need to create four views: +NetBox provides the `@register_model_view()` decorator to register views with the model view registry. +NetBox uses this registry to determine which views exist for a model and which actions should appear in the UI. -* **Detail view** - Display a single object -* **List view** - Displays a table of all existing instances of a particular model -* **Edit view** - Handles adding and modifying objects -* **Delete view** - Handles the deletion of an object +This decorator is optional. If you prefer, you can register your views manually. +Using the decorator tends to keep things consistent and reduces boilerplate. -### AccessList Views +A few decorator arguments you will see in this step: -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. +* `name` is used as part of the view name for URL reversing +* `path` controls the URL path fragment for the view (optional) +* `detail` should be `True` for views tied to a specific object and `False` for views attached to the list path -Let's start with a detail view. We subclass `generic.ObjectView` and define the queryset of objects we want to display. +More details are available in the NetBox documentation: [view URL registration](https://netboxlabs.com/docs/netbox/plugins/development/views/#url-registration-1). + +## AccessList Views + +The general pattern we will follow here is to subclass a generic view class provided by NetBox and define the necessary attributes. +We will not need to write much custom logic because the NetBox provided views handle most of the request flow for us. + +### Detail view + +The detail view shows a single object. +We use `generic.ObjectView` and provide a queryset. ```python +@register_model_view(models.AccessList) class AccessListView(generic.ObjectView): queryset = models.AccessList.objects.all() ``` -: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. +:green_circle: **Tip:** NetBox views expect a queryset rather than just a model. That makes it easy to add optimizations later, like `prefetch_related()` or annotations. -Next, we'll add a list view. For this view, we need to define both `queryset` and `table`. +### List view -```python -class AccessListListView(generic.ObjectListView): - queryset = models.AccessList.objects.all() - table = tables.AccessListTable -``` +The list view shows a table of objects. +We need both `queryset` and `table`. -: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. +In Step 3 we added a `rule_count` column to the AccessList table. +That column expects a count of rules for each access list in the queryset, named `rule_count`. -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()`](https://docs.djangoproject.com/en/stable/ref/models/querysets/#aggregation-functions) function to extend the SQL query and annotate the count of associated rules. (Don't forget to add the import statement up top.) +We can use Django's [`Count()`](https://docs.djangoproject.com/en/stable/ref/models/querysets/#aggregation-functions) function to annotate the number of associated rules. ```python -from django.db.models import Count -# ... +@register_model_view(models.AccessList, name='list', path='', detail=False) class AccessListListView(generic.ObjectListView): queryset = models.AccessList.objects.annotate( - rule_count=Count('rules') + rule_count=Count('rules'), ) table = tables.AccessListTable ``` -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. +:green_circle: **Tip:** The names `AccessListView` and `AccessListListView` are easy to mix up at a glance. `AccessListView` is the detail view (one object). `AccessListListView` is the list view (many objects). + +### Edit and delete views + +The edit view handles both add and edit actions. +We register it twice: + +* `add` registers the view at the collection level (not tied to a specific object) +* `edit` registers the view for a specific object ```python +@register_model_view(models.AccessList, name='add', detail=False) +@register_model_view(models.AccessList, name='edit') class AccessListEditView(generic.ObjectEditView): queryset = models.AccessList.objects.all() form = forms.AccessListForm +``` +The delete view is straightforward: + +```python +@register_model_view(models.AccessList, name='delete') class AccessListDeleteView(generic.ObjectDeleteView): queryset = models.AccessList.objects.all() ``` -That's it for the first model! We'll create another four views for `AccessListRule` as well. - -### AccessListRule Views +## AccessListRule Views -The rest of our views follow the same pattern as the first four. +The `AccessListRule` views follow the same pattern. +Adding a short comment block can make the file easier to scan later. ```python +# +# AccessListRule views +# + +@register_model_view(models.AccessListRule) class AccessListRuleView(generic.ObjectView): queryset = models.AccessListRule.objects.all() +@register_model_view(models.AccessListRule, name='list', path='', detail=False) class AccessListRuleListView(generic.ObjectListView): queryset = models.AccessListRule.objects.all() table = tables.AccessListRuleTable +@register_model_view(models.AccessListRule, name='add', detail=False) +@register_model_view(models.AccessListRule, name='edit') class AccessListRuleEditView(generic.ObjectEditView): queryset = models.AccessListRule.objects.all() form = forms.AccessListRuleForm +@register_model_view(models.AccessListRule, name='delete') class AccessListRuleDeleteView(generic.ObjectDeleteView): queryset = models.AccessListRule.objects.all() ``` -With our views in place, we now need to make them accessible by associating each with a URL. - -## Map Views to URLs - -In the `netbox_access_lists/` directory, create `urls.py`. This will define our view URLs. - -```bash -$ edit urls.py -``` - -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. - -First we'll need to import Django's `path` function from its `urls` module, as well as our plugin's `models` and `views` modules. - -```python -from django.urls import path -from . import models, views -``` - -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. - -```python -urlpatterns = ( - path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), - path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), - path('access-lists//', views.AccessListView.as_view(), name='accesslist'), - path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), - path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), -) -``` - -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()`. - -: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. - -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. - -```python -urlpatterns = ( - - # Access lists - path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), - path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), - path('access-lists//', views.AccessListView.as_view(), name='accesslist'), - path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), - path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), - - # Access list rules - path('rules/', views.AccessListRuleListView.as_view(), name='accesslistrule_list'), - path('rules/add/', views.AccessListRuleEditView.as_view(), name='accesslistrule_add'), - path('rules//', views.AccessListRuleView.as_view(), name='accesslistrule'), - path('rules//edit/', views.AccessListRuleEditView.as_view(), name='accesslistrule_edit'), - path('rules//delete/', views.AccessListRuleDeleteView.as_view(), name='accesslistrule_delete'), - -) -``` - -### Adding Changelog Views - -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. - -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`: - -```python -from netbox.views.generic import ObjectChangeLogView -``` - -Then, we'll add an extra path for each model inside `urlpatterns`: - -```python -urlpatterns = ( - - # Access lists - # ... - path('access-lists//changelog/', ObjectChangeLogView.as_view(), name='accesslist_changelog', kwargs={ - 'model': models.AccessList - }), - - # Access list rules - # ... - path('rules//changelog/', ObjectChangeLogView.as_view(), name='accesslistrule_changelog', kwargs={ - 'model': models.AccessListRule - }), - -) -``` - -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). - -## Add Model URL Methods - -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). - -Back in `models.py`, import Django's `reverse` function from its `urls` module at the top of the file: - -```python -from django.urls import reverse -``` - -Then, add the `get_absolute_url()` method to the `AccessList` class after its `__str__()` method: - -```python -class AccessList(NetBoxModel): - # ... - def get_absolute_url(self): - return reverse('plugins:netbox_access_lists:accesslist', args=[self.pk]) -``` - -`reverse()` takes two arguments here: The view name, and a list of positional arguments. The view name is formed by concatenating three components: - -* The string `'plugins'` -* The name of our plugin -* The name of the desired URL path (defined as `name='accesslist'` in `urls.py`) - -The object's `pk` attribute is passed as well, and replaces the `` path converter in the URL. - -We'll add a `get_absolute_url()` method for `AccessListRule` as well, adjusting the view name accordingly. - -```python -class AccessListRule(NetBoxModel): - # ... - def get_absolute_url(self): - return reverse('plugins:netbox_access_lists:accesslistrule', args=[self.pk]) -``` - -## Test the Views - -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. - -The first `access-lists` in the URL is the `base_url` we defined in `__init__.py`. -The second is the path we defined in `urls.py`. - -: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. - -![Access lists list view](/images/step05-accesslist-list.png) - -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. - -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't work yet, but the form should render as seen below.) - -![Access list creation form](/images/step05-accesslist-form.png) - -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! - -: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. +With our views in place, the next step is to make them reachable by associating them with URLs.
-:arrow_left: [Step 4: Forms](/tutorial/step04-forms.md) | [Step 6: Templates](/tutorial/step06-templates.md) :arrow_right: +:arrow_left: [Step 4: Forms](/tutorial/step04-forms.md) | [Step 6: URLs](/tutorial/step06-urls.md) :arrow_right:
- diff --git a/tutorial/step06-templates.md b/tutorial/step06-templates.md deleted file mode 100644 index a27e01a..0000000 --- a/tutorial/step06-templates.md +++ /dev/null @@ -1,239 +0,0 @@ -# Step 6: Templates - -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. - -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. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step05-views`. - -## Template File Structure - -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: - -```bash -$ cd netbox_access_lists/ -$ mkdir -p templates/netbox_access_lists/ -``` - -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. - -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. - -## Create the AccessList Template - -Begin by creating the file `accesslist.html` in the plugin's template directory. - -```bash -$ edit templates/netbox_access_lists/accesslist.html -``` - -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: - -``` -{% extends 'generic/object.html' %} -``` - -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. - -Let's extend the generic template's `content` block with some information about the access list. - -``` -{% block content %} -
-
-
-
Access List
-
-
- - - - - - - - - - - - -
Name{{ object.name }}
Default Action{{ object.get_default_action_display }}
Rules{{ object.rules.count }}
- - - {% include 'inc/panels/custom_fields.html' %} - -
- {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} -
- -{% endblock content %} -``` - -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. - -: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. - -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. - -: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`). - -![Access list view](/images/step06-accesslist1.png) - -This is nice, but it would be handy to include the access list's assigned rules on the page as well. - -### Add a Rules Table - -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. - -```python -class AccessListView(generic.ObjectView): - queryset = models.AccessList.objects.all() - - def get_extra_context(self, request, instance): - table = tables.AccessListRuleTable(instance.rules.all()) - table.configure(request) - - return { - 'rules_table': table, - } -``` - -This method does three things: - -1. Instantiate `AccessListRuleTable` with a queryset matching all rules assigned to this access list -2. Configure the table instance according to the current request (to honor user preferences) -3. Return a dictionary of context data referencing the table instance - -This makes the table available to our template as the `rules_table` context variable. Let's add it to our template. - -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: - -``` -{% load render_table from django_tables2 %} -``` - -Then, immediately above the `{% endblock content %}` line at the end of the file, insert the following template code: - -``` -
-
-
-
Rules
-
- {% render_table rules_table %} -
-
-
-
-``` - -After refreshing the access list view in the browser, you should now see the rules table at the bottom of the page. - -![Access list view with rules table](/images/step06-accesslist2.png) - -## Create the AccessListRule Template - -Speaking of rules, let's not forget about our `AccessListRule` model: It needs a template too. Create `accesslistrule.html` alongside our first template: - -```bash -$ edit templates/netbox_access_lists/accesslistrule.html -``` - -And copy the content below: - -``` -{% extends 'generic/object.html' %} - -{% block content %} -
-
-
-
Access List Rule
-
- - - - - - - - - - - - - -
Access List - {{ object.access_list }} -
Index{{ object.index }}
Description{{ object.description|placeholder }}
-
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} -
-
-
-
Details
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
Protocol{{ object.get_protocol_display }}
Source Prefix - {% if object.source_prefix %} - {{ object.source_prefix }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Source Ports{{ object.source_ports|join:", "|placeholder }}
Destination Prefix - {% if object.destination_prefix %} - {{ object.destination_prefix }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
Destination Ports{{ object.destination_ports|join:", "|placeholder }}
Action{{ object.get_action_display }}
-
-
-
-
-{% endblock content %} -``` - -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: - -* 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. -* NetBox's `placeholder` filter is applied to the rule's description. (This renders a — for empty fields.) -* 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. - -![Access list rule view](/images/step06-accesslistrule.png) - -Feel free to experiment with different layouts and content before proceeding with the next step. - -
- -:arrow_left: [Step 5: Views](/tutorial/step05-views.md) | [Step 7: Navigation](/tutorial/step07-navigation.md) :arrow_right: - -
- diff --git a/tutorial/step06-urls.md b/tutorial/step06-urls.md new file mode 100644 index 0000000..87da682 --- /dev/null +++ b/tutorial/step06-urls.md @@ -0,0 +1,255 @@ +# Step 6: URLs + +The views we created in the previous step exist as Python classes, but NetBox still needs to know which URL paths should route to which views. In this step we will map our views to URLs in one of two ways: + +* Use NetBox's `get_model_urls()` helper (recommended, works great with `register_model_view()`) +* Define every URL path manually (useful if you prefer explicit routing) + +This guide assumes you are using the decorator-based approach, but the manual approach is included as an option. + +:blue_square: **Note:** If you skipped the previous step, and you cloned the demo repository, run `git checkout step05-views` (in case you've cloned the repository `netbox-plugin-demo`). + +## Map Views to URLs using the decorator + +In the `netbox_access_lists/` directory of your plugin project root, create `urls.py`. This file defines the URL patterns for your plugin. + +```bash +cd netbox_access_lists/ +touch urls.py +``` + +Open `urls.py` and add the imports below. + +We need: + +* Django's `include()` and `path()` +* NetBox's `get_model_urls()` helper +* our `views` module (important, so the decorators execute and register views) + +```python +from django.urls import include, path +from utilities.urls import get_model_urls + +from . import views +``` + +:green_circle: **Tip:** `views` is not referenced directly in this file, but importing it ensures the `@register_model_view()` decorators run and populate the model view registry. + +### Add AccessList URLs + +`get_model_urls()` can generate all URL patterns for a model based on the views registered via `register_model_view()`. In practice, you will usually include it twice for each model: + +* once for list level routes (where `detail=False`) +* once for object detail routes (where the path contains ``) + +Add the following to `urls.py`: + +```python +urlpatterns = ( + # Access lists + path( + 'access-lists/', + include(get_model_urls('netbox_access_lists', 'accesslist', detail=False)), + ), + path( + 'access-lists//', + include(get_model_urls('netbox_access_lists', 'accesslist')), + ), +) +``` + +We chose `access lists` as the base URL path for the `AccessList` model. You can choose something else, but keeping a consistent scheme makes it easier to reason about your plugin and aligns with how NetBox structures many of its own URLs. + +`get_model_urls()` takes these arguments: + +* `app_label`: the label of the app containing the model (for example `netbox_access_lists`) +* `model_name`: the model name in lowercase (for example `accesslist`) +* `detail`: `True` if the routes apply to a specific object, `False` for list level routes + +:green_circle: **Tip:** `` is a [path converter](https://docs.djangoproject.com/en/stable/topics/http/urls/#path-converters). It captures an integer named `pk` from the URL and passes it to the view so the correct object can be retrieved. + +:blue_square: **Note:** This approach also registers additional model features when available (for example, changelog and journal entries), as long as the relevant views exist. + +### Add AccessListRule URLs + +Now add the URL includes for `AccessListRule` by extending `urlpatterns`: + +```python +urlpatterns = ( + # Access lists + path( + 'access-lists/', + include(get_model_urls('netbox_access_lists', 'accesslist', detail=False)), + ), + path( + 'access-lists//', + include(get_model_urls('netbox_access_lists', 'accesslist')), + ), + + # Access list rules + path( + 'rules/', + include(get_model_urls('netbox_access_lists', 'accesslistrule', detail=False)), + ), + path( + 'rules//', + include(get_model_urls('netbox_access_lists', 'accesslistrule')), + ), +) +``` + +For reference, your plugin project should now include `urls.py`: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── tables.py +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +## Manually map views to URLs + +:warning: **Warning:** This section is only required if you prefer to define URL patterns manually. If you are using `get_model_urls()`, you can skip ahead to [Test the Views](#test-the-views). + +If you want manual routing, use a `urls.py` that maps each path to a view explicitly. + +Open `urls.py` and replace its contents with the following. + +URL mapping for NetBox plugins is pretty much identical to regular Django apps. +We define `urlpatterns` as an iterable of `path()` calls, mapping URL fragments to view classes. + +First, import Django's `path` function, along with our plugin `models` and `views` modules: + +```python +from django.urls import path + +from . import models, views +``` + +We have four views per model, but we need 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 also specify a `name` so we can reference URLs easily in code. + +```python +urlpatterns = ( + path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), + path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), + path('access-lists//', views.AccessListView.as_view(), name='accesslist'), + path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), + path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), +) +``` + +We chose `access-lists` as the base URL for our `AccessList` model, but you are free to choose something else. +However, it is recommended to retain the naming scheme shown, as several NetBox features rely on it. + +:green_circle: **Tip:** When passing a class based view to `path()`, make sure you call `as_view()`. + +Now add the rest of the paths. +You might find it helpful to group paths by model to keep the file easy to scan. + +```python +urlpatterns = ( + + # Access lists + path('access-lists/', views.AccessListListView.as_view(), name='accesslist_list'), + path('access-lists/add/', views.AccessListEditView.as_view(), name='accesslist_add'), + path('access-lists//', views.AccessListView.as_view(), name='accesslist'), + path('access-lists//edit/', views.AccessListEditView.as_view(), name='accesslist_edit'), + path('access-lists//delete/', views.AccessListDeleteView.as_view(), name='accesslist_delete'), + + # Access list rules + path('rules/', views.AccessListRuleListView.as_view(), name='accesslistrule_list'), + path('rules/add/', views.AccessListRuleEditView.as_view(), name='accesslistrule_add'), + path('rules//', views.AccessListRuleView.as_view(), name='accesslistrule'), + path('rules//edit/', views.AccessListRuleEditView.as_view(), name='accesslistrule_edit'), + path('rules//delete/', views.AccessListRuleDeleteView.as_view(), name='accesslistrule_delete'), + +) +``` + +### Adding changelog views manually + +NetBox supports automatic [change logging](https://netbox.readthedocs.io/en/stable/additional-features/change-logging/). +You can see this in the Changelog tab on many object detail pages. + +If you are using the decorator-based approach, these extra views are often handled for you. +If you are mapping URLs manually, you can add changelog routes using `ObjectChangeLogView`. + +First, import it: + +```python +from netbox.views.generic import ObjectChangeLogView +``` + +Then add an extra path for each model: + +```python +urlpatterns = ( + + # Access lists + # ... + path('access-lists//changelog/', ObjectChangeLogView.as_view(), name='accesslist_changelog', kwargs={ + 'model': models.AccessList + }), + + # Access list rules + # ... + path('rules//changelog/', ObjectChangeLogView.as_view(), name='accesslistrule_changelog', kwargs={ + 'model': models.AccessListRule + }), + +) +``` + +Notice that we use `ObjectChangeLogView` directly here. +We do not need model-specific subclasses for it. +We pass the `model` kwarg so the view knows which model to work with. + +## Test the Views + +Now for the moment of truth: do the views show up in the UI? + +Make sure the development server is running, then open a browser and navigate to: + +```text +http://localhost:8000/plugins/access-lists/access-lists/ +``` + +You should see the access list list view and, if you created objects in Step 2, an access list named `MyACL1`. + +The first `access lists` part in the URL comes from the plugin `base_url` you defined in `__init__.py`. +The second part comes from the paths you defined in `urls.py`. + +:blue_square: **Note:** This guide assumes you are running the Django development server locally on port 8000. If your setup differs, adjust the URL accordingly. + +![Access lists list view](/images/step06-accesslist-list.png) + +You should see the `name`, `rule_count`, and `default_action` columns we defined in Step 3. +The `rule_count` value should show two rules if you followed the example data creation in Step 2. + +If you click the Add button in the top right, you should reach the access list creation form: + +![Access list creation form](/images/step06-accesslist-form.png) + +If you click an access list name in the table, you will likely hit a `TemplateDoesNotExist` error. +That is expected at this point because we have not created the object templates yet. +We will fix that in the next step. + +:blue_square: **Note:** You might also notice that adding a rule fails with a `NoReverseMatch` error. This happens because the dynamic form fields require REST API endpoints for plugin models. We will build the REST API pieces later in the tutorial. + +
+ +:arrow_left: [Step 5: Views](/tutorial/step05-views.md) | [Step 7: Templates](/tutorial/step07-templates.md) :arrow_right: + +
diff --git a/tutorial/step07-navigation.md b/tutorial/step07-navigation.md deleted file mode 100644 index 7df27a4..0000000 --- a/tutorial/step07-navigation.md +++ /dev/null @@ -1,115 +0,0 @@ -# Step 7: Navigation - -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. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step06-templates`. - -## Adding Navigation Menu Items - -Begin by creating `navigation.py` in the `netbox_access_lists/` directory. - -```bash -$ cd netbox_access_lists/ -$ edit navigation.py -``` - -We'll need to import the [`PluginMenuItem`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/navigation/#menu-items) class provided by NetBox to add new menu items; do this at the top of the file. - -```python -from extras.plugins import PluginMenuItem -``` - -Next, we'll create a tuple named `menu_items`. This will hold our customized `PluginMenuItem` instances. - -```python -menu_items = () -``` - -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: - -* `link` - The name of the URL path to which we're linking -* `link_text` - The text of the link - -Create two instances of `PluginMenuItem` within `menu_items`: - -```python -menu_items = ( - PluginMenuItem( - link='plugins:netbox_access_lists:accesslist_list', - link_text='Access Lists' - ), - PluginMenuItem( - link='plugins:netbox_access_lists:accesslistrule_list', - link_text='Access List Rules' - ), -) -``` - -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. - -:blue_square: **Note:** If the menu items do not appear, try restarting the development server (`manage.py runserver`). - -![Navigation menu items](/images/step07-menu-items1.png) - -That's much more convenient! - -### Adding Menu Buttons - -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 one additional class at the top of `navigation.py`: `PluginMenuButton`. - -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -``` - -`PluginMenuButton` is used similarly to `PluginMenuItem`: Instantiate it with the necessary keyword arguments to effect a menu button. These arguments are: - -* `link` - The name of the URL path to which the button links -* `title` - The text displayed when the user hovers over the button -* `icon_class` - CSS class name(s) indicating the icon to display - -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. - -```python -accesslist_buttons = [ - PluginMenuButton( - link='plugins:netbox_access_lists:accesslist_add', - title='Add', - icon_class='mdi mdi-plus-thick', - ) -] - -accesslistrule_buttons = [ - PluginMenuButton( - link='plugins:netbox_access_lists:accesslistrule_add', - title='Add', - icon_class='mdi mdi-plus-thick', - ) -] -``` - -The buttons can then be passed to the menu items via the `buttons` keyword argument: - -```python -menu_items = ( - PluginMenuItem( - link='plugins:netbox_access_lists:accesslist_list', - link_text='Access Lists', - buttons=accesslist_buttons - ), - PluginMenuItem( - link='plugins:netbox_access_lists:accesslistrule_list', - link_text='Access List Rules', - buttons=accesslistrule_buttons - ), -) -``` - -Now we should see green "add" buttons appear next to our menu links. - -![Navigation menu items with buttons](/images/step07-menu-items2.png) - -
- -:arrow_left: [Step 6: Templates](/tutorial/step06-templates.md) | [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) :arrow_right: - -
diff --git a/tutorial/step07-templates.md b/tutorial/step07-templates.md new file mode 100644 index 0000000..daedbb9 --- /dev/null +++ b/tutorial/step07-templates.md @@ -0,0 +1,373 @@ +# Step 7: Templates + +Templates are responsible for rendering HTML content for NetBox views. +Each template is a file that mixes HTML with template code. +In most plugins, each model will need its own template for the object detail view. +Templates can also be created or customized for other views, but the default templates NetBox provides are usually a great fit. + +NetBox uses the [Django Template Language](https://docs.djangoproject.com/en/stable/topics/templates/) (DTL). +It will feel familiar if you have used [Jinja2](https://jinja2docs.readthedocs.io/en/stable/), but there are important differences. +DTL is intentionally more limited in the logic it can run. +For example, you cannot execute arbitrary Python code inside a template. +If you plan to build more complex layouts, it is worth reviewing the Django template documentation first. + +:blue_square: **Note:** If you skipped the previous step, run `git checkout step06-urls` (if you cloned the repository `netbox-plugin-demo`). + +## Template File Structure + +NetBox looks for templates within a `templates/` subdirectory (if it exists) inside your plugin package root (for example `netbox_access_lists/`). +Inside `templates/`, create a subdirectory named after the plugin (for example `netbox_access_lists`): + +```bash +cd netbox_access_lists/ +mkdir -p templates/netbox_access_lists/ +``` + +Your template files will live in this directory. + +NetBox provides default templates for most generic views. +The main exception is `ObjectView` (the single object detail view), so we need to create templates for: + +* `AccessListView` +* `AccessListRuleView` + +By default, each `ObjectView` subclass looks for a template named after its model. +In our case: + +* `AccessListView` looks for `netbox_access_lists/accesslist.html` +* `AccessListRuleView` looks for `netbox_access_lists/accesslistrule.html` + +You *can* override this by setting `template_name` on the view, but the default behavior works well for this tutorial. + +For reference, your plugin project should now include the templates directory: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +## Create the AccessList Template + +Create the file `accesslist.html` in the plugin template directory: + +```bash +touch templates/netbox_access_lists/accesslist.html +``` + +Even though we are creating our own template, NetBox has already done a lot of work for us. +We can extend the built-in object template. At the top of the file, add: + +```django +{% extends 'generic/object.html' %} +{% load helpers %} +``` + +This tells Django to load NetBox's `generic/object.html` first, then render whatever we define inside `block` tags. + +:green_circle: **Tip:** We load `helpers` so we can use NetBox template tags and filters like `badge`, `linkify`, and `placeholder`. + +Now add a `content` block that displays some basic information about the access list: + +```html +{% block content %} +
+
+
+

Access List

+ + + + + + + + + + + + + +
Name{{ object.name }}
Default Action{% badge object.get_default_action_display bg_color=object.get_default_action_color %}
Rule Count{{ object.rules.count }}
+
+ {% include 'inc/panels/custom_fields.html' %} +
+
+ {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} +
+
+{% endblock content %} +``` + +This layout uses one Bootstrap row with two columns: + +* The left column shows a card with key fields plus the custom fields panel +* The right column shows the tags and comments panels + +:green_circle: **Tip:** If you are unsure how to structure a page, NetBox's own core templates are a great source of examples. + +Now try it out. +Go back to the list view at , then click an access list name to open its detail page. +You should see a page similar to the image below. + +:blue_square: **Note:** If NetBox still says the template does not exist, restart the development server (`manage.py runserver`) and try again. + +![Access list view](/images/step07-accesslist1.png) + +This is a solid start, but it would be even more useful if the access list page also showed its rules. + +There are two common ways to do this: + +* Extend the existing template to include a rules table on the same page +* Add a separate tab for rules on the access list detail view + +You can pick either approach. +If you implement both, you will see the rules twice, which is usually not what you want. + +### Extend the Existing Template to Include a Rules Table + +To render a rules table, we first need to provide additional context data from the view. + +Open `views.py` and find the `AccessListView` class. Add a `get_extra_context()` method like this: + +```python +@register_model_view(models.AccessList) +class AccessListView(generic.ObjectView): + queryset = models.AccessList.objects.all() + + def get_extra_context(self, request, instance): + """Add rules table to access list view context.""" + rules = instance.rules.restrict(request.user, 'view') + rules_table = tables.AccessListRuleTable(rules) + rules_table.columns.hide('access_list') # Hide AccessList column + rules_table.configure(request) + + return { + 'rules_table': rules_table, + } +``` + +This method: + +1. Builds a queryset of rules the current user is allowed to view +2. Creates an `AccessListRuleTable` for those rules +3. Hides the `access_list` column since we are already viewing the parent access list +4. Configures the table for the current request so user preferences are respected +5. Exposes the table in the template as `rules_table` + +Now update `accesslist.html` so it can render the table. + +First, load `render_table` from `django_tables2` by adding this near the top of the file under the existing `{% load helpers %}` line: + +```django +{% load render_table from django_tables2 %} +``` + +Then, add the card below near the end of the `content` block, just before `{% endblock content %}`: + +```html +
+
+
+
Rules
+
+ {% render_table rules_table %} +
+
+
+
+``` + +Refresh the access list detail page. +You should now see the rules table at the bottom: + +![Access list view with rules table](/images/step07-accesslist2.png) + +### Add a Separate Tab to the Access List View + +If you would rather show rules on their own tab, you can use `ObjectChildrenView`. + +First, update your imports in `views.py` to include `ViewTab`. For example: + +```python +from utilities.views import ViewTab, register_model_view +``` + +Then add this view class below `AccessListView`: + +```python +@register_model_view(models.AccessList, 'rules') +class AccessListRulesView(generic.ObjectChildrenView): + queryset = models.AccessList.objects.all() + child_model = models.AccessListRule + table = tables.AccessListRuleTable + tab = ViewTab( + label='Rules', + badge=lambda obj: obj.rules.count(), + permission='netbox_access_lists.view_accesslistrule', + weight=500, + ) + + def get_children(self, request, parent): + return parent.rules.restrict(request.user, 'view').all() + + def get_table(self, *args, **kwargs): + rules_table = super().get_table(*args, **kwargs) + rules_table.columns.hide('access_list') # Hide AccessList column + return rules_table +``` + +An `ObjectChildrenView` is similar to an `ObjectView`, but it renders a child object table on its own tab. Here: + +* `child_model` tells NetBox which related objects to display +* `table` defines how to render those objects +* `tab` controls how the tab appears in the UI (label, optional badge, permission, and ordering) + +The `get_children` method is called to retrieve the child objects to display in the tab. +It limits the returned queryset to objects the user has permission to view (`.restrict(request.user, 'view')`). + +Reload the access list detail page, and you should see a new Rules tab. +When you click it, you will get the rules table on that tab: + +![Access list view with rules tab](/images/step07-accesslist3.png) + +## Create the AccessListRule Template + +Now we need a template for `AccessListRule` as well. +Create `accesslistrule.html` next to the first template: + +```bash +touch templates/netbox_access_lists/accesslistrule.html +``` + +Add the following content: + +```html +{% extends 'generic/object.html' %} +{% load helpers %} + +{% block content %} +
+
+
+

Access List Rule

+ + + + + + + + + + + + + +
Access List{{ object.access_list|linkify }}
Index{{ object.index }}
Description{{ object.description|placeholder }}
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} +
+
+
+

Details

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Protocol{% badge object.get_protocol_display bg_color=object.get_protocol_color %}
Source Prefix{{ object.source_prefix|linkify|placeholder }}
Source Ports + {% if object.source_ports %} + {{ object.source_ports|join:", " }} + {% else %} + {{ ""|placeholder }} + {% endif %} +
Destination Prefix{{ object.destination_prefix|linkify|placeholder }}
Destination Ports + {% if object.destination_ports %} + {{ object.destination_ports|join:", " }} + {% else %} + {{ ""|placeholder }} + {% endif %} +
Action{% badge object.get_action_display bg_color=object.get_action_color %}
+
+
+
+{% endblock content %} +``` + +A few details are worth calling out: + +* `linkify` turns related objects into links when possible. +* `placeholder` renders a friendly placeholder for empty values. +* For ports, we use an `{% if %}` block so `join` is only called when a list is present. +* `badge` renders colored labels for choice fields. In templates, you reference display helpers without parentheses, for example `object.get_action_display`. 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. The color is determined by the `get_protocol_color()` and `get_action_color()` methods. + +Once you save the file, open a rule detail page in the UI and you should see something like this: + +![Access list rule view](/images/step07-accesslistrule.png) + +Feel free to experiment with different layouts and content before moving on. + +For reference, your plugin project should now look like this: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +
+ +:arrow_left: [Step 6: URLs](/tutorial/step06-urls.md) | [Step 8: Navigation](/tutorial/step08-navigation.md) :arrow_right: + +
diff --git a/tutorial/step08-filter-sets.md b/tutorial/step08-filter-sets.md deleted file mode 100644 index 9b90678..0000000 --- a/tutorial/step08-filter-sets.md +++ /dev/null @@ -1,136 +0,0 @@ -# Step 8: Filter Sets - -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. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step07-navigation`. - -## Create a Filter Set - -Begin by creating `filtersets.py` in the `netbox_access_lists/` directory. - -```bash -$ cd netbox_access_lists/ -$ edit filtersets.py -``` - -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.) - -```python -from netbox.filtersets import NetBoxModelFilterSet -from .models import AccessListRule -``` - -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. - -```python -class AccessListRuleFilterSet(NetBoxModelFilterSet): - - class Meta: - model = AccessListRule - fields = ('id', 'access_list', 'index', 'protocol', 'action') -``` - -`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. - -```python - def search(self, queryset, name, value): - return queryset.filter(description__icontains=value) -``` - -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. - -:warning: **Warning:** It is important that the name of the module containing the filter sets is `filtersets` and that the names of the filter set classes are identical to the name of the underlying model with `FilterSet` appended. For the model `netbox_access_lists.models.AccessList` the filter set class **must** be accessible as `netbox_access_lists.filtersets.AccessListFilterSet`. - -While any other naming scheme will seemingly work, there are some features of NetBox that rely on the exact naming of the filter set module and the classes. For example, GraphQL filters and selectors in dynamic model fields will not work if the naming is different. - -## Create a Filter Form - -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`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#netboxmodelfiltersetform) to the existing import statement for `netbox.forms`: - -```python -from django import forms -# ... -from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm -``` - -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). - -```python -class AccessListRuleFilterForm(NetBoxModelFilterSetForm): - model = AccessListRule -``` - -:blue_square: **Note:** Note that the `model` attribute is declared directly under the class: We don't need a `Meta` child class. - -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. - -```python - access_list = forms.ModelMultipleChoiceField( - queryset=AccessList.objects.all(), - required=False - ) -``` - -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. - -: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`. - -Next we'll add a field for the `position` filter: This is an integer field, so `IntegerField` should work nicely: - -```python - index = forms.IntegerField( - required=False - ) -``` - -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`: - -```python -from .models import AccessList, AccessListRule, ActionChoices, ProtocolChoices -``` - -Then add the form fields to `AccessListRuleFilterForm`: - -```python - protocol = forms.MultipleChoiceField( - choices=ProtocolChoices, - required=False - ) - action = forms.MultipleChoiceField( - choices=ActionChoices, - required=False - ) -``` - -## Update the View - -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: - -```python -from . import filtersets, forms, models, tables -``` - -Then, add the `filterset` and `filterset_form` attributes to `AccessListRuleListView`: - -```python -class AccessListRuleListView(generic.ObjectListView): - queryset = models.AccessListRule.objects.all() - table = tables.AccessListRuleTable - filterset = filtersets.AccessListRuleFilterSet - filterset_form = forms.AccessListRuleFilterForm -``` - -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. - -![Access list rules filter form](/images/step08-filter-form.png) - -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. - -: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. - -
- -:arrow_left: [Step 7: Navigation](/tutorial/step07-navigation.md) | [Step 9: REST API](/tutorial/step09-rest-api.md) :arrow_right: - -
- diff --git a/tutorial/step08-navigation.md b/tutorial/step08-navigation.md new file mode 100644 index 0000000..7a54b2c --- /dev/null +++ b/tutorial/step08-navigation.md @@ -0,0 +1,295 @@ +# Step 8: Navigation + +So far, we have been entering URLs manually to access our plugin views. That works for development, but it is not very friendly for regular use. +In this step, we will add links to the NetBox navigation menu so users can reach our pages with a click. + +:blue_square: **Note:** If you skipped the previous step and you cloned `netbox-plugin-demo`, run `git checkout step07-templates`. + +## Add navigation menu items + +Begin by creating `navigation.py` in the `netbox_access_lists/` directory of your plugin project root. + +```bash +cd netbox_access_lists/ +touch navigation.py +``` + +We will use NetBox's navigation classes to add menu items and optional buttons: + +* [`PluginMenuItem`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/navigation/#menu-items) for menu links +* [`PluginMenu`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/navigation/#menu-groups) for grouping links under a plugin section +* [`PluginMenuButton`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/navigation/#menu-buttons) for shortcut buttons (for example, Add) + +At the top of the file, import the navigation classes provided by NetBox: + +```python +from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem +``` + +### Create menu items + +We will add a link to the list view for each model. This is done by creating a `PluginMenuItem` with (at minimum) two arguments: + +* `link` is the name of the URL we want to link to +* `link_text` is the text shown in the menu + +Create two `PluginMenuItem` objects and assign them to `accesslist_item` and `accesslistrule_item`: + +```python +# Access List +accesslist_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslist_list', + link_text='Access Lists', +) + +# Access List Rule +accesslistrule_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslistrule_list', + link_text='Access List Rules', +) +``` + +### Group menu items + +Next, we will group our items under a single plugin menu section. We will: + +* label the plugin menu `Access Lists` +* create two groups, `Access Lists` and `Rules` +* use a lock icon for the menu section + +Add this to the end of `navigation.py`: + +```python +menu = PluginMenu( + label='Access Lists', + groups=( + ( + 'Access Lists', + (accesslist_item,), + ), + ( + 'Rules', + (accesslistrule_item,), + ), + ), + icon_class='mdi mdi-lock', +) +``` + +When you reload NetBox, you should see a new `Access Lists` section appear in the navigation menu with the two links. + +:blue_square: **Note:** If the menu items do not appear, restart the development server (`manage.py runserver`) and refresh the page. + +![Navigation menu items](/images/step08-menu-items1.png) + +## Add menu buttons + +We can also add buttons to the menu items, which is handy for quick access to add forms. + +A `PluginMenuButton` supports: + +* `link` is the URL name the button should open +* `title` is the tooltip text shown on hover +* `icon_class` controls the icon that is displayed + +Create button lists above the `PluginMenuItem` definitions: + +```python +accesslist_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslist_add', + title='Add', + icon_class='mdi mdi-plus-thick', + ) +] + +accesslistrule_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslistrule_add', + title='Add', + icon_class='mdi mdi-plus-thick', + ) +] +``` + +Then attach them to the menu items using the `buttons` argument: + +```python +# Access List +accesslist_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslist_list', + link_text='Access Lists', + buttons=accesslist_buttons, +) + +# Access List Rule +accesslistrule_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslistrule_list', + link_text='Access List Rules', + buttons=accesslistrule_buttons, +) +``` + +Now you should see a plus icon appear next to each menu item while hovering. + +:blue_square: **Note:** Menu buttons are only shown while hovering over a menu item. + +![Navigation menu items with buttons](/images/step08-menu-items2.png) + +## Add permission constraints + +Although permissions are not fully covered in this tutorial, we can still add basic permission constraints so menu items and buttons only appear for users who are allowed to use them. + +To do this, add the `permissions` argument to both `PluginMenuItem` and `PluginMenuButton`. + +Permission strings follow the format `app_label.codename`, for example: + +* `netbox_access_lists.view_accesslist` +* `netbox_access_lists.add_accesslist` + +### Add permissions to buttons + +```python +# Access List +accesslist_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslist_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_access_lists.add_accesslist'], + ) +] + +# Access List Rule +accesslistrule_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslistrule_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_access_lists.add_accesslistrule'], + ) +] +``` + +### Add permissions to menu items + +```python +# Access List +accesslist_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslist_list', + link_text='Access Lists', + permissions=['netbox_access_lists.view_accesslist'], + buttons=accesslist_buttons, +) + +# Access List Rule +accesslistrule_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslistrule_list', + link_text='Access List Rules', + permissions=['netbox_access_lists.view_accesslistrule'], + buttons=accesslistrule_buttons, +) +``` + +## Final `navigation.py` + +Your complete `navigation.py` should now look like this: + +```python +from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem + + +# +# Define plugin menu buttons +# + + +# Access List +accesslist_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslist_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_access_lists.add_accesslist'], + ) +] + +# Access List Rule +accesslistrule_buttons = [ + PluginMenuButton( + link='plugins:netbox_access_lists:accesslistrule_add', + title='Add', + icon_class='mdi mdi-plus-thick', + permissions=['netbox_access_lists.add_accesslistrule'], + ) +] + +# +# Define plugin menu items +# + +# Access List +accesslist_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslist_list', + link_text='Access Lists', + permissions=['netbox_access_lists.view_accesslist'], + buttons=accesslist_buttons, +) + +# Access List Rule +accesslistrule_item = PluginMenuItem( + link='plugins:netbox_access_lists:accesslistrule_list', + link_text='Access List Rules', + permissions=['netbox_access_lists.view_accesslistrule'], + buttons=accesslistrule_buttons, +) + +# +# Define plugin menu groups +# + +menu = PluginMenu( + label='Access Lists', + groups=( + ( + 'Access Lists', + (accesslist_item,), + ), + ( + 'Rules', + (accesslistrule_item,), + ), + ), + icon_class='mdi mdi-lock', +) +``` + +For reference, your plugin project should now include `navigation.py`: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +
+ +:arrow_left: [Step 7: Templates](/tutorial/step07-templates.md) | [Step 9: Filter Sets](/tutorial/step09-filter-sets.md) :arrow_right: + +
diff --git a/tutorial/step09-filter-sets.md b/tutorial/step09-filter-sets.md new file mode 100644 index 0000000..92f10e2 --- /dev/null +++ b/tutorial/step09-filter-sets.md @@ -0,0 +1,491 @@ +# Step 9: Filter Sets + +Filters let users request only a specific subset of objects matching a query, for example, filtering the Sites list by status or region. + +NetBox uses the [django filters](https://django-filter.readthedocs.io/en/stable/) library to build and apply filter sets for models. +In this step we will create a filter set for our plugin models, so the list view and REST API can apply the same kind of filtering. +For brevity, we will only create a filter set for `AccessListRule`, but the same approach applies to `AccessList` too. + +In this step we will build: + +* a **filter set** for `AccessListRule` (the query logic) +* a **filter form** so the UI can render a nice filter panel + +:blue_square: **Note:** If you skipped the previous step, and you cloned the demo repository, run `git checkout step08-navigation`. + +## Create a filter set + +Begin by creating `filtersets.py` in the `netbox_access_lists/` directory of your plugin project root. + +```bash +cd netbox_access_lists/ +touch filtersets.py +``` + +### Start with a basic FilterSet + +Open `filtersets.py` and add the imports below. We will import: + +* `NetBoxModelFilterSet` as our base class +* `AccessListRule` as the model we want to filter +* `django_filters` because we will define some custom filters +* `Prefix` because our model references it +* `NumericArrayFilter` because our ports are stored as integer arrays + +```python +import django_filters + +from ipam.models import Prefix +from netbox.filtersets import NetBoxModelFilterSet +from utilities.filters import NumericArrayFilter + +from .models import AccessListRule +``` + +Next, create a class named `AccessListRuleFilterSet` that subclasses `NetBoxModelFilterSet`. + +Inside it, define a `Meta` class and set: + +* `model` to the model we are filtering +* `fields` to the list of model fields we want NetBox and django filters to expose it automatically + +This should feel familiar if you have already built a model form. +The idea is very similar. + +```python +class AccessListRuleFilterSet(NetBoxModelFilterSet): + + class Meta: + model = AccessListRule + fields = ('id', 'access_list', 'index', 'protocol', 'action') +``` + +`NetBoxModelFilterSet` handles some important functions for us, including support for filtering by custom field values and tags. + +:warning: **Warning:** NetBox relies on a specific naming convention for filter sets. + +The module must be named `filtersets`, and the FilterSet class name must match the model name with `FilterSet` appended. +For example, the model `netbox_access_lists.models.AccessList` must have a filter set class accessible as `netbox_access_lists.filtersets.AccessListFilterSet`. + +Other naming schemes may appear to work at first, but certain NetBox features can break, including GraphQL filtering and selectors for dynamic model fields. + +### Add a simple search implementation + +`NetBoxModelFilterSet` also provides a general purpose `q` filter. +When a user enters a value in the search box, NetBox calls the filter set `search()` method. +By default, `search()` does nothing, so we will override it. + +Add this method under the `Meta` class: + +```python + def search(self, queryset, name, value): + return queryset.filter(description__icontains=value) +``` + +This returns all rules whose description contains the search string. +You can expand this later to match other fields as well. + +### Add Prefix Filters + +Our model has two foreign key fields: + +* `source_prefix` +* `destination_prefix` + +Both point to NetBox's `Prefix` model. We want to let users filter rules by prefix in two ways: + +* by the prefix value itself (example `192.0.2.0/24`) +* by the prefix ID (useful for API users and for dynamic form widgets) + +To do this, we will use `django_filters.ModelMultipleChoiceFilter`. +This filter type allows users to pass one or more values. + +Add these filters to the `AccessListRuleFilterSet` class, above the `Meta` class and update the `fields` list: + +```python + source_prefix = django_filters.ModelMultipleChoiceFilter( + field_name='source_prefix__prefix', + queryset=Prefix.objects.all(), + to_field_name='prefix', + label='Source Prefix (value)', + ) + source_prefix_id = django_filters.ModelMultipleChoiceFilter( + field_name='source_prefix', + queryset=Prefix.objects.all(), + to_field_name='id', + label='Source Prefix (ID)', + ) + destination_prefix = django_filters.ModelMultipleChoiceFilter( + field_name='destination_prefix__prefix', + queryset=Prefix.objects.all(), + to_field_name='prefix', + label='Destination Prefix (value)', + ) + destination_prefix_id = django_filters.ModelMultipleChoiceFilter( + field_name='destination_prefix', + queryset=Prefix.objects.all(), + to_field_name='id', + label='Destination Prefix (ID)', + ) + + class Meta: + model = AccessListRule + fields = ('id', 'access_list', 'index', 'protocol', 'action') +``` + +A quick explanation of the key arguments: + +* `field_name` tells django filters which field to filter on + * it can traverse relationships using Django double underscore syntax + * example: `source_prefix__prefix` means follow `source_prefix` to the related `Prefix` object, then use its `prefix` field +* `queryset` is used to look up valid related objects +* `to_field_name` tells the filter which field on the related model should match the user supplied values + +:blue_square: **Note:** We are defining `source_prefix_id` and `destination_prefix_id` as custom filters. Because they are explicitly declared on the class, they do not need to be listed in `Meta.fields`. + +:blue_square: **Note:** When filtering on a field of a related model, you will usually use double underscore traversal in `field_name`. The value of `to_field_name` should match the field you want to accept as input, like `id` or `prefix`. + +### Filter by source and destination ports + +The model fields `source_ports` and `destination_ports` are stored as a list of integers, so we will use `NumericArrayFilter` to filter by values contained in those lists. + +We want a simple behavior: return rules where the list contains a specific port number. That is why we use `lookup_expr="contains"`. + +Add these filters to the `AccessListRuleFilterSet` class: + +```python + source_port = NumericArrayFilter( + field_name='source_ports', + lookup_expr='contains', + label='Source Port', + ) + destination_port = NumericArrayFilter( + field_name='destination_ports', + lookup_expr='contains', + label='Destination Port', + ) +``` + +:blue_square: **Note:** Just like the prefix ID filters, `source_port` and `destination_port` are custom filters. Because they are explicitly declared on the class, they do not need to be listed in `Meta.fields`. + +### Full `filtersets.py` so far + +At this point, your `filtersets.py` should look like this: + +```python +import django_filters + +from ipam.models import Prefix +from netbox.filtersets import NetBoxModelFilterSet +from utilities.filters import NumericArrayFilter + +from .models import AccessListRule + + +class AccessListRuleFilterSet(NetBoxModelFilterSet): + source_prefix = django_filters.ModelMultipleChoiceFilter( + field_name='source_prefix__prefix', + queryset=Prefix.objects.all(), + to_field_name='prefix', + label='Source Prefix (value)', + ) + source_prefix_id = django_filters.ModelMultipleChoiceFilter( + field_name='source_prefix', + queryset=Prefix.objects.all(), + to_field_name='id', + label='Source Prefix (ID)', + ) + destination_prefix = django_filters.ModelMultipleChoiceFilter( + field_name='destination_prefix__prefix', + queryset=Prefix.objects.all(), + to_field_name='prefix', + label='Destination Prefix (value)', + ) + destination_prefix_id = django_filters.ModelMultipleChoiceFilter( + field_name='destination_prefix', + queryset=Prefix.objects.all(), + to_field_name='id', + label='Destination Prefix (ID)', + ) + + source_port = NumericArrayFilter( + field_name='source_ports', + lookup_expr='contains', + label='Source Port', + ) + destination_port = NumericArrayFilter( + field_name='destination_ports', + lookup_expr='contains', + label='Destination Port', + ) + + class Meta: + model = AccessListRule + fields = ('id', 'access_list', 'index', 'protocol', 'action') + + def search(self, queryset, name, value): + return queryset.filter(description__icontains=value) +``` + +For reference, your plugin project should now include `filtersets.py`: + +```text +. +├── netbox_access_lists +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +### Enable advanced filtering + +NetBox provides a mechanism for plugins to register filter sets for use in the advanced filtering UI (sometimes referenced as filter modifiers). +This can be enabled by adding a decorator to the filter set class. + +First, import the `register_filterset` decorator from `utilities.filtersets` at the top of `filtersets.py`: + +```python +from utilities.filtersets import register_filterset +``` + +Then add the decorator to each FilterSet class, like `AccessListRuleFilterSet`: + +```python +@register_filterset +class AccessListRuleFilterSet(NetBoxModelFilterSet): + # ... +``` + +:blue_square: **Note:** This is a NetBox v4.5 feature. If you are reading this tutorial while using an older NetBox version, this decorator will not be available. + +## Create a Filter Form + +The filter set defines how filtering works, but it does not automatically create the UI fields for the NetBox filter panel. +For that we need a filter form class. + +We will add this to `forms.py`. + +### Update imports in `forms.py` + +Open `forms.py` and make the following updates: + +* import Django `forms` +* import [`NetBoxModelFilterSetForm`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/forms/#netboxmodelfiltersetform) +* import the ChoiceSets for protocol and action +* import `Prefix` and `AccessList` because our filter fields need querysets +* import a few NetBox utility fields for dynamic selection and tags + +Add or extend your imports so they include: + +```python +from django import forms + +from ipam.models import Prefix +from netbox.forms import NetBoxModelFilterSetForm, NetBoxModelForm +from utilities.forms.fields import ( + CommentField, + DynamicModelChoiceField, + DynamicModelMultipleChoiceField, + TagFilterField, +) + +from .choices import ActionChoices, ProtocolChoices +from .models import AccessList, AccessListRule +``` + +:blue_square: **Note:** You might already have some of these imports from earlier steps. If so, you only need to add the missing ones. + +### Create `AccessListRuleFilterForm` + +Create a form class named `AccessListRuleFilterForm` subclassing `NetBoxModelFilterSetForm`. +Unlike `NetBoxModelForm`, a filter form does not need a `Meta` class. It just needs a `model` attribute. + +```python +class AccessListRuleFilterForm(NetBoxModelFilterSetForm): + model = AccessListRule +``` + +Next, define a form field for each filter you want to appear in the UI. +Every field should use `required=False`, because filters are optional. + +### Add basic fields + +Start with the `access_list` filter. This references a related object, so we will use `ModelMultipleChoiceField` to allow filtering by multiple objects: + +```python + access_list = forms.ModelMultipleChoiceField( + queryset=AccessList.objects.all(), + required=False, + ) +``` + +:blue_square: **Note:** We are using Django `ModelMultipleChoiceField` for this field instead of NetBox `DynamicModelMultipleChoiceField` because the dynamic field requires a functional REST API endpoint for the model. Once we implement the plugin REST API in Step 10, you can revisit this form and switch `access_list` to a dynamic field. + +Next, add a field for the `index` filter: + +```python + index = forms.IntegerField( + required=False, + ) +``` + +For the `protocol` and `action` fields, we want choice based filters. +Use `MultipleChoiceField` to allow selecting one or more choices: + +```python + protocol = forms.MultipleChoiceField( + choices=ProtocolChoices, + required=False, + ) + action = forms.MultipleChoiceField( + choices=ActionChoices, + required=False, + ) +``` + +Finally, add fields for the ports: + +```python + source_port = forms.IntegerField( + label='Source Port', + required=False, + ) + destination_port = forms.IntegerField( + label='Destination Port', + required=False, + ) +``` + +### Add dynamic prefix fields + +Even though we cannot use `DynamicModelMultipleChoiceField` for `access_list` yet, we can use it for prefixes because NetBox already has a REST API endpoint for `Prefix`. + +The dynamic field returns prefix IDs, so we will use the ID based filters `source_prefix_id` and `destination_prefix_id` in the form. + +```python + source_prefix_id = DynamicModelMultipleChoiceField( + queryset=Prefix.objects.all(), + required=False, + label='Source Prefix', + ) + destination_prefix_id = DynamicModelMultipleChoiceField( + queryset=Prefix.objects.all(), + required=False, + label='Destination Prefix', + ) +``` + +### Add tag filtering + +Filtering by tags is a special case. This requires a custom form field: + +```python + tag = TagFilterField(model) +``` + +### Group filter fields + +NetBox provides a mechanism for grouping filter fields into logical sections. +This is optional, but it can make the filter form easier to scan. + +Grouping is achieved by adding a `fieldsets` attribute to the form class. +This attribute is a tuple of `FieldSet` objects, where each `FieldSet` contains an optional section name and a list of field names. +The section name is displayed as a header in the filter form. + +First, import `FieldSet`: + +```python +from utilities.forms.rendering import FieldSet +``` + +Then add `fieldsets` to the form class: + +```python +class AccessListRuleFilterForm(NetBoxModelFilterSetForm): + model = AccessListRule + fieldsets = ( + FieldSet( + 'q', + 'filter_id', + 'tag', + ), + FieldSet( + 'access_list', + 'index', + 'protocol', + 'action', + name='Attributes', + ), + FieldSet( + 'source_prefix_id', + 'source_port', + name='Source', + ), + FieldSet( + 'destination_prefix_id', + 'destination_port', + name='Destination', + ), + ) + # the fields continue below +``` + +You probably noticed that we also added `filter_id`. +This enables the Saved Filters feature in the UI. The `q` and `filter_id` fields are provided by the base form. + +## Update the View + +Now we need to tell the list view to use our filter set and filter form. + +Open `views.py` and extend the existing imports to include the `filtersets` module: + +```bash +edit views.py +``` + +```python +from . import filtersets, forms, models, tables +``` + +Then add the `filterset` and `filterset_form` attributes to `AccessListRuleListView`: + +```python +@register_model_view(models.AccessListRule, name='list', path='', detail=False) +class AccessListRuleListView(generic.ObjectListView): + queryset = models.AccessListRule.objects.all() + table = tables.AccessListRuleTable + filterset = filtersets.AccessListRuleFilterSet + filterset_form = forms.AccessListRuleFilterForm +``` + +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, you will find the fields you created on `AccessListRuleFilterForm`, as well as the search field. + +![Access list rules filter form](/images/step09-filter-form.png) + +If you have not 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. + +:green_circle: **Tip:** You may notice that we did not add a form field for the model `id` filter. This is because it is unlikely to be useful for a human using the UI. However, we still want to support filtering objects by their primary keys because it is very helpful for consumers of the NetBox REST API, which we will cover next. + +
+ +:arrow_left: [Step 8: Navigation](/tutorial/step08-navigation.md) | [Step 10: REST API](/tutorial/step10-rest-api.md) :arrow_right: + +
diff --git a/tutorial/step09-rest-api.md b/tutorial/step09-rest-api.md deleted file mode 100644 index 4127595..0000000 --- a/tutorial/step09-rest-api.md +++ /dev/null @@ -1,249 +0,0 @@ -# Step 9: REST API - -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. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step08-filter-sets`. - -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: - -```bash -$ cd netbox_access_lists/ -$ mkdir api -$ touch api/__init__.py -``` - -## Create Model Serializers - -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. - -```bash -$ edit api/serializers.py -``` - -At the top of this file, we need to import the `serializers` module from the `rest_framework` library, as well as NetBox's [`NetBoxModelSerializer`](https://netboxlabs.com/docs/netbox/en/stable/plugins/development/rest-api/) class and our plugin's own models: - -```python -from rest_framework import serializers - -from netbox.api.serializers import NetBoxModelSerializer -from ..models import AccessList, AccessListRule -``` - -### Create AccessListSerializer - -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. - -```python -class AccessListSerializer(NetBoxModelSerializer): - - class Meta: - model = AccessList - fields = ( - 'id', 'display', 'name', 'default_action', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', - ) -``` - -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. - -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`. - -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: - -```python -class AccessListSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='plugins-api:netbox_access_lists-api:accesslist-detail' - ) -``` - -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. - -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: - -```python -rule_count = serializers.IntegerField(read_only=True) -``` - -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. - -Finally, we need to add both `url` and `rule_count` to `Meta.fields`: - -```python - class Meta: - model = AccessList - fields = ( - 'id', 'url', 'display', 'name', 'default_action', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'rule_count', - ) -``` - -:green_circle: **Tip:** The order in which fields are listed determines the order in which they appear in the object's API representation. - -### Create AccessListRuleSerializer - -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. - -```python -class AccessListRuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' - ) - - class Meta: - model = AccessListRule - fields = ( - 'id', 'url', 'display', 'access_list', 'index', 'protocol', 'source_prefix', 'source_ports', - 'destination_prefix', 'destination_ports', 'action', 'tags', 'custom_fields', 'created', - 'last_updated', - ) -``` - -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_. - -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: - -```python -from ipam.api.serializers import PrefixSerializer -# ... -class AccessListRuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail') - source_prefix = PrefixSerializer(nested=True) - destination_prefix = PrefixSerializer(nested=True) -``` - -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. - -### Create Nested Serializers - -Begin by importing NetBox's `WritableNestedSerializer` class. This will serve as the base class for our nested serializers. - -```python -from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer -``` - -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). - -```python -class NestedAccessListSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='plugins-api:netbox_access_lists-api:accesslist-detail' - ) - - class Meta: - model = AccessList - fields = ('id', 'url', 'display', 'name') - -class NestedAccessListRuleSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' - ) - - class Meta: - model = AccessListRule - fields = ('id', 'url', 'display', 'index') -``` - -Now we can override the `access_list` field on `AccessListRuleSerializer` to use the nested serializer: - -```python -class AccessListRuleSerializer(NetBoxModelSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' - ) - access_list = NestedAccessListSerializer() - source_prefix = PrefixSerializer(nested=True) - destination_prefix = PrefixSerializer(nested=True) -``` - -## Create the Views - -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. - -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. - -```python -from netbox.api.viewsets import NetBoxModelViewSet - -from .. import filtersets, models -from .serializers import AccessListSerializer, AccessListRuleSerializer -``` - -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.) - -```python -class AccessListViewSet(NetBoxModelViewSet): - queryset = models.AccessList.objects.prefetch_related('tags') - serializer_class = AccessListSerializer -``` - -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. - -```python -from django.db.models import Count -# ... -class AccessListViewSet(NetBoxModelViewSet): - queryset = models.AccessList.objects.prefetch_related('tags').annotate( - rule_count=Count('rules') - ) - serializer_class = AccessListSerializer -``` - -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. - -```python -class AccessListRuleViewSet(NetBoxModelViewSet): - queryset = models.AccessListRule.objects.prefetch_related( - 'access_list', 'source_prefix', 'destination_prefix', 'tags' - ) - serializer_class = AccessListRuleSerializer - filterset_class = filtersets.AccessListRuleFilterSet -``` - -## Create the Endpoint URLs - -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. - -Create `api/urls.py` and import NetBox's `NetBoxRouter` and our API views: - -```python -from netbox.api.routers import NetBoxRouter -from . import views -``` - -Next, we'll define an `app_name`. This will be used to resolve API view names for our plugin. - -```python -app_name = 'netbox_access_list' -``` - -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/`. - -```python -router = NetBoxRouter() -router.register('access-lists', views.AccessListViewSet) -router.register('access-list-rules', views.AccessListRuleViewSet) -``` - -Finally, we expose the router's `urls` attribute as `urlpatterns` so that it will be detected by the plugins framework. - -```python -urlpatterns = router.urls -``` - -: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. - -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. - -:blue_square: **Note:** If the REST API endpoints do not load, try restarting the development server (`manage.py runserver`). - -![REST API - Root view](/images/step09-rest-api1.png) - -![REST API - Access list rules](/images/step09-rest-api2.png) - -
- -:arrow_left: [Step 8: Filter Sets](/tutorial/step08-filter-sets.md) | [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) :arrow_right: - -
- diff --git a/tutorial/step10-graphql-api.md b/tutorial/step10-graphql-api.md deleted file mode 100644 index fbc3bda..0000000 --- a/tutorial/step10-graphql-api.md +++ /dev/null @@ -1,91 +0,0 @@ -# Step 10: GraphQL API - -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. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step09-rest-api`. - -Begin by creating `graphql.py`. This will hold our object type and query classes. - -```bash -$ cd netbox_access_lists/ -$ edit graphql.py -``` - -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. - -```python -from graphene import ObjectType -from netbox.graphql.types import NetBoxObjectType -from netbox.graphql.fields import ObjectField, ObjectListField -from . import filtersets, models -``` - -## Create the Object Types - -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. - -```python -class AccessListType(NetBoxObjectType): - - class Meta: - model = models.AccessList - fields = '__all__' - - -class AccessListRuleType(NetBoxObjectType): - - class Meta: - model = models.AccessListRule - fields = '__all__' - filterset_class = filtersets.AccessListRuleFilterSet -``` - -## Create the Query - -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. - -```python -class Query(ObjectType): - access_list = ObjectField(AccessListType) - access_list_list = ObjectListField(AccessListType) - - access_list_rule = ObjectField(AccessListRuleType) - access_list_rule_list = ObjectListField(AccessListRuleType) -``` - -Then we just need to expose our query class to the plugins framework as `schema`: - -```python -schema = Query -``` - -:green_circle: **Tip:** The path to the query class can be changed by setting `graphql_schema` in the plugin's configuration class. - -To try out the GraphQL API, open `` in a browser and enter the following query: - -``` -query { - access_list_list { - id - name - rules { - index - action - description - } - } -} -``` - -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.) - -![GraphiQL interface](/images/step10-graphiql.png) - -This completes the plugin development tutorial. Well done! Now you're all set to make a plugin of your own! - -
- -:arrow_left: [Step 9: REST API](/tutorial/step09-rest-api.md) | [Step 11: Search](/tutorial/step11-search.md) :arrow_right: - -
- diff --git a/tutorial/step10-rest-api.md b/tutorial/step10-rest-api.md new file mode 100644 index 0000000..f59e7ae --- /dev/null +++ b/tutorial/step10-rest-api.md @@ -0,0 +1,405 @@ +# Step 10: REST API + +The REST API enables powerful integrations with other systems that exchange data with NetBox. + +NetBox's API is built on the [Django REST Framework](https://www.django-rest-framework.org/) (DRF). +DRF is not part of Django itself, but it works very well with Django models and querysets. +In this step, we will extend NetBox's REST API to serve our plugin models. + +:blue_square: **Note:** If you skipped the previous step, run `git checkout step09-filter-sets` (if you cloned the repository `netbox-plugin-demo`). + +Our API code will live in an `api/` package inside `netbox_access_lists/`. +Create the directory and an `__init__.py` file: + +```bash +cd netbox_access_lists/ +mkdir api +touch api/__init__.py +``` + +## Create Model Serializers + +Serializers are somewhat analogous to forms. +A form renders HTML and validates user input from the UI. +A serializer defines how a model is represented in the API (usually as JSON), and how input data from the API is validated and converted into Python objects. + +We will create one serializer per model. + +Begin by creating `serializers.py` in the `api/` directory: + +```bash +touch api/serializers.py +``` + +At the top of this file, import: + +* NetBox's `NetBoxModelSerializer` base class +* DRF's `serializers` module (we will use it for fields like `HyperlinkedIdentityField`) +* NetBox's `PrefixSerializer` so we can include nested prefix data +* our plugin models + +```python +from ipam.api.serializers import PrefixSerializer +from netbox.api.serializers import NetBoxModelSerializer +from rest_framework import serializers + +from ..models import AccessList, AccessListRule +``` + +### Create `AccessListSerializer` + +Create a serializer for `AccessList` by subclassing `NetBoxModelSerializer`. +Much like a model form, we define a `Meta` class that tells the serializer which model it is for, and which fields to include. + +Start with the URL field. +Every serializer should include a read only `url` field that points to the API endpoint for this object. +We create this using DRF's `HyperlinkedIdentityField`. + +```python +class AccessListSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:accesslist-detail' + ) +``` + +The `view_name` here is the name of the API view that will serve a single access list object. +We will create it later when we register our API viewsets and URLs. + +Next, remember the `rule_count` column we added for the table in Step 3. We can expose that value in the API too. +This is a computed field, so we make it read only and we will annotate it in the view queryset. + +```python + rule_count = serializers.IntegerField(read_only=True) +``` + +Now add the `Meta` class: + +```python + class Meta: + model = AccessList + fields = ( + 'id', + 'url', + 'display', + 'name', + 'default_action', + 'rule_count', + 'comments', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) +``` + +A quick overview of some common fields: + +* `id` is the primary key. This is a must have for every serializer. +* `display` is provided by `NetBoxModelSerializer`. It is read only, and returns a human friendly string representation of the object. +* `tags` and `custom_fields` are provided by NetBox's model and serializer helpers. +* `created` and `last_updated` come from `NetBoxModel` and are read only. + +:green_circle: **Tip:** The order in `fields` is the order you will see in the API response. Many NetBox serializers put `tags`, `custom_fields`, `created`, and `last_updated` near the end. + +### Create `AccessListRuleSerializer` + +Now create a serializer for `AccessListRule`. +This will look similar, but it includes several foreign keys and list fields. + +Start with the URL field again: + +```python +class AccessListRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' + ) +``` + +Now define the `Meta` class. Make sure we include `description`, since it is part of the model and useful for API consumers. + +```python + class Meta: + model = AccessListRule + fields = ( + 'id', + 'url', + 'display', + 'access_list', + 'index', + 'protocol', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'action', + 'description', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) +``` + +#### Nested serializers for related objects + +By default, serializers often represent related objects by primary key only. +That works, but it forces clients to make extra requests just to display basic info like a name. + +NetBox supports nested representations for related objects, which usually include an `id`, a `url`, and a `display` value. +This is very convenient for both humans and integrations. + +For `source_prefix` and `destination_prefix`, we can use NetBox's nested prefix serializer: + +```python +class AccessListRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' + ) + source_prefix = PrefixSerializer(nested=True, required=False, allow_null=True) + destination_prefix = PrefixSerializer(nested=True, required=False, allow_null=True) +``` + +We also want a nested representation for `access_list`. +To do that, we will make sure our `AccessListSerializer` defines `brief_fields`, and then reuse it with `nested=True`. + +### Enable nested representations for plugin models + +`NetBoxModelSerializer` supports nested mode using `brief_fields`. +These fields define what should be included when the serializer is used with `nested=True`. + +Update both serializer `Meta` classes to include `brief_fields`: + +```python +class AccessListSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:accesslist-detail' + ) + rule_count = serializers.IntegerField(read_only=True) + + class Meta: + model = AccessList + fields = ( + 'id', + 'url', + 'display', + 'name', + 'default_action', + 'rule_count', + 'comments', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) + brief_fields = ('id', 'url', 'display', 'name') + + +class AccessListRuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:netbox_access_lists-api:accesslistrule-detail' + ) + access_list = AccessListSerializer(nested=True) + source_prefix = PrefixSerializer(nested=True, required=False, allow_null=True) + destination_prefix = PrefixSerializer(nested=True, required=False, allow_null=True) + + class Meta: + model = AccessListRule + fields = ( + 'id', + 'url', + 'display', + 'access_list', + 'index', + 'protocol', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'action', + 'description', + 'tags', + 'custom_fields', + 'created', + 'last_updated', + ) + brief_fields = ('id', 'url', 'display', 'index') +``` + +:green_circle: **Tip:** It is good practice to include `id`, `url`, and `display` in `brief_fields`. + +For reference, your plugin project should now include `api/serializers.py`: + +```text +. +├── netbox_access_lists +│ ├── api +│ │ ├── __init__.py +│ │ └── serializers.py +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +## Create the API viewsets + +Next, we need API views. +In the UI, we created multiple views per model (detail, list, edit, delete). +For the REST API, DRF uses viewsets. +A viewset is a single class that can handle the common API operations for a model, like listing objects, retrieving one object, creating, updating, and deleting. + +Create `api/views.py`: + +```bash +touch api/views.py +``` + +Add the imports below: + +```python +from django.db.models import Count +from netbox.api.viewsets import NetBoxModelViewSet + +from .. import filtersets, models +from .serializers import AccessListSerializer, AccessListRuleSerializer +``` + +### AccessList viewset + +Create a viewset for access lists. We set: + +* `queryset` to the objects we want to expose +* `serializer_class` to the serializer we created earlier + +We also annotate `rule_count` so the serializer field is populated. + +```python +class AccessListViewSet(NetBoxModelViewSet): + queryset = models.AccessList.objects.prefetch_related('tags').annotate( + rule_count=Count('rules') + ) + serializer_class = AccessListSerializer +``` + +### AccessListRule viewset + +Now create a viewset for rules. +Here we also connect the filter set from Step 9 using `filterset_class`. +This is what allows the list view endpoint to support query filtering. + +For performance, we load foreign key relationships using `select_related`, and tags using `prefetch_related`. + +```python +class AccessListRuleViewSet(NetBoxModelViewSet): + queryset = models.AccessListRule.objects.select_related( + 'access_list', 'source_prefix', 'destination_prefix' + ).prefetch_related('tags') + serializer_class = AccessListRuleSerializer + filterset_class = filtersets.AccessListRuleFilterSet +``` + +## Create the endpoint URLs + +Finally, we need to expose these viewsets under API URLs. +This works a bit differently from UI URLs. +Instead of defining many `path()` entries, we register each viewset with a router. + +Create `api/urls.py`: + +```bash +touch api/urls.py +``` + +Add the imports: + +```python +from netbox.api.routers import NetBoxRouter + +from . import views +``` + +Set `app_name`. +NetBox uses this namespace to resolve view names, including the `view_name` values we used in our serializers. + +```python +app_name = 'netbox_access_lists-api' +``` + +Now create a router and register each viewset: + +```python +router = NetBoxRouter() +router.register('access-lists', views.AccessListViewSet) +router.register('access-list-rules', views.AccessListRuleViewSet) + +urlpatterns = router.urls +``` + +:green_circle: **Tip:** The base URL for plugin API endpoints is determined by the `base_url` in your plugin config (Step 1). In our case, the API root is under `/api/plugins/access-lists/`. + +## Test the API + +With all REST API components in place, you should be able to browse the plugin API endpoints. + +While logged into NetBox, open: + + + +You should see the available endpoints. +Clicking on an endpoint will show a list view, and from there you can click through to individual objects. + +:blue_square: **Note:** If the REST API endpoints do not load, restart the development server (`manage.py runserver`) and refresh the page. + +![REST API root view](/images/step10-rest-api1.png) + +![REST API access list rules](/images/step10-rest-api2.png) + +For reference, your plugin project should now include the full `api/` directory: + +```text +. +├── netbox_access_lists +│ ├── api +│ │ ├── __init__.py +│ │ ├── serializers.py +│ │ ├── urls.py +│ │ └── views.py +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +
+ +:arrow_left: [Step 9: Filter Sets](/tutorial/step09-filter-sets.md) | [Step 11: GraphQL API](/tutorial/step11-graphql-api.md) :arrow_right: + +
diff --git a/tutorial/step11-graphql-api.md b/tutorial/step11-graphql-api.md new file mode 100644 index 0000000..5e2b3e2 --- /dev/null +++ b/tutorial/step11-graphql-api.md @@ -0,0 +1,365 @@ +# Step 11: GraphQL API + +In addition to its REST API, NetBox also provides a [GraphQL](https://graphql.org/) API. +GraphQL is useful when you want to request exactly the fields you need, including related objects, in a single query. + +NetBox builds its GraphQL API using the [Strawberry GraphQL](https://github.com/strawberry-graphql/strawberry) and [Strawberry Django](https://github.com/strawberry-graphql/strawberry-django) libraries. + +:blue_square: **Note:** If you skipped the previous step, run `git checkout step09-rest-api` (in case you've cloned the repository `netbox-plugin-demo`). + +Our GraphQL code will live in a `graphql/` package inside `netbox_access_lists/`. +Create the directory and an `__init__.py` file: + +```bash +cd netbox_access_lists/ +mkdir graphql +touch graphql/__init__.py +``` + +## Create enum types + +GraphQL requires enums for fields that have a fixed set of allowed values. +In our plugin, we already have fixed choices defined as ChoiceSets: + +* `ActionChoices` for `AccessList.default_action` and `AccessListRule.action` +* `ProtocolChoices` for `AccessListRule.protocol` + +NetBox ChoiceSets provide a helper to convert them into Python Enum classes, and Strawberry can convert those Enum classes into GraphQL enums. + +Create `graphql/enums.py`: + +```bash +touch graphql/enums.py +``` + +Add the imports and enum definitions: + +```python +import strawberry + +from ..choices import ActionChoices, ProtocolChoices + + +ActionEnum = strawberry.enum(ActionChoices.as_enum()) +ProtocolEnum = strawberry.enum(ProtocolChoices.as_enum()) +``` + +## Create GraphQL filters + +For REST API filtering, we used `FilterSet` classes from Step 9. +GraphQL filtering is separate, so we create dedicated GraphQL filter classes. + +NetBox provides `NetBoxModelFilter` to make this easier. + +Create `graphql/filters.py`: + +```bash +touch graphql/filters.py +``` + +Add the imports: + +```python +from typing import TYPE_CHECKING, Annotated + +import strawberry +import strawberry_django +from netbox.graphql.filters import NetBoxModelFilter +from strawberry import ID +from strawberry_django import FilterLookup + +from .. import models +``` + +### Why `TYPE_CHECKING` and `strawberry.lazy` + +In GraphQL schemas it is common for types and filters to reference each other. +If we import everything normally, we can end up with circular imports. + +To avoid that, we do two things: + +* import types only inside an `if TYPE_CHECKING:` block so they are available to type checkers +* reference those types using `strawberry.lazy(...)` so Strawberry can resolve them later + +Add this below the imports: + +```python +if TYPE_CHECKING: + from ipam.graphql.filters import PrefixFilter + from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup + + from .enums import ActionEnum, ProtocolEnum +``` + +### AccessListFilter + +Create a filter class for `AccessList`. +The `@strawberry_django.filter(...)` decorator connects the filter class to the model. + +```python +@strawberry_django.filter(models.AccessList, lookups=True) +class AccessListFilter(NetBoxModelFilter): + name: FilterLookup[str] | None = strawberry_django.filter_field() + default_action: ( + Annotated['ActionEnum', strawberry.lazy('netbox_access_lists.graphql.enums')] + | None + ) = strawberry_django.filter_field() +``` + +A few notes: + +* `lookups=True` enables lookup operators like `exact`, `icontains`, `in`, and so on, depending on the field type. +* `FilterLookup[str]` means the field supports string lookups. +* `default_action` is an enum value, so we annotate it with `ActionEnum`. + +### AccessListRuleFilter + +Now create a filter class for `AccessListRule`. +This includes: + +* a nested filter for `access_list` +* ID based filters for related objects (useful and common) +* integer lookups for `index` +* integer array lookups for ports +* prefix filters for source and destination prefixes +* enum filters for protocol and action + +Add this below `AccessListFilter`: + +```python +@strawberry_django.filter(models.AccessListRule, lookups=True) +class AccessListRuleFilter(NetBoxModelFilter): + access_list: ( + Annotated[ + 'AccessListFilter', strawberry.lazy('netbox_access_lists.graphql.filters') + ] + | None + ) = strawberry_django.filter_field() + access_list_id: ID | None = strawberry_django.filter_field() + index: ( + Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] + | None + ) = strawberry_django.filter_field() + protocol: ( + Annotated['ProtocolEnum', strawberry.lazy('netbox_access_lists.graphql.enums')] + | None + ) = strawberry_django.filter_field() + source_prefix: ( + Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None + ) = strawberry_django.filter_field() + source_prefix_id: ID | None = strawberry_django.filter_field() + source_ports: ( + Annotated[ + 'IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups') + ] + | None + ) = strawberry_django.filter_field() + destination_prefix: ( + Annotated['PrefixFilter', strawberry.lazy('ipam.graphql.filters')] | None + ) = strawberry_django.filter_field() + destination_prefix_id: ID | None = strawberry_django.filter_field() + destination_ports: ( + Annotated[ + 'IntegerArrayLookup', strawberry.lazy('netbox.graphql.filter_lookups') + ] + | None + ) = strawberry_django.filter_field() + action: ( + Annotated['ActionEnum', strawberry.lazy('netbox_access_lists.graphql.enums')] + | None + ) = strawberry_django.filter_field() +``` + +At this point, `graphql/filters.py` is complete. + +## Create the object types + +GraphQL needs object types to describe what fields are available and how models relate to each other. +NetBox provides `NetBoxObjectType` as a convenient base class. + +Create `graphql/types.py`: + +```bash +touch graphql/types.py +``` + +Add the imports: + +```python +from typing import TYPE_CHECKING, Annotated, List + +import strawberry +import strawberry_django +from netbox.graphql.types import NetBoxObjectType + +from .. import models +from . import filters +``` + +For prefix fields, we reference the existing type from NetBox IPAM: + +```python +if TYPE_CHECKING: + from ipam.graphql.types import PrefixType +``` + +Now define types for both models. + +We use `fields="__all__"` to include all model fields automatically, and we connect the GraphQL filters using `filters=...`. + +```python +@strawberry_django.type( + models.AccessList, fields='__all__', filters=filters.AccessListFilter +) +class AccessListType(NetBoxObjectType): + # Related models + rules: List[ + Annotated[ + 'AccessListRuleType', strawberry.lazy('netbox_access_lists.graphql.types') + ] + ] + + +@strawberry_django.type( + models.AccessListRule, fields='__all__', filters=filters.AccessListRuleFilter +) +class AccessListRuleType(NetBoxObjectType): + # Model fields + access_list: Annotated[ + 'AccessListType', strawberry.lazy('netbox_access_lists.graphql.types') + ] + source_prefix: Annotated['PrefixType', strawberry.lazy('ipam.graphql.types')] + destination_prefix: Annotated['PrefixType', strawberry.lazy('ipam.graphql.types')] +``` + +A few notes: + +* `AccessListType.rules` exposes the reverse foreign key relationship. +* `AccessListRuleType.access_list` exposes the forward foreign key relationship. +* `source_prefix` and `destination_prefix` reuse NetBox core GraphQL types. + +## Create the query + +Now we need to expose our types through GraphQL query fields. +NetBox will merge plugin query classes into the main schema. + +Create `graphql/schema.py`: + +```bash +touch graphql/schema.py +``` + +Add the imports: + +```python +import strawberry +import strawberry_django + +from .types import AccessListType, AccessListRuleType +``` + +Now define a query class. +We will provide a single object field and a list field for each model. + +```python +@strawberry.type(name='Query') +class NetBoxAccessListQuery: + access_list: AccessListType = strawberry_django.field() + access_list_list: list[AccessListType] = strawberry_django.field() + + access_list_rule: AccessListRuleType = strawberry_django.field() + access_list_rule_list: list[AccessListRuleType] = strawberry_django.field() +``` + +In general: + +* the single object fields let you fetch one object, typically by ID +* the list fields let you fetch collections and can support filtering + +## Register the schema with the plugin + +Finally, expose your query class from `graphql/__init__.py`. + +Open `graphql/__init__.py`: + +```bash +touch graphql/__init__.py +``` + +Add: + +```python +from .schema import NetBoxAccessListQuery + +schema = [NetBoxAccessListQuery] +``` + +:green_circle: **Tip:** The plugin config can also control schema discovery using the `graphql_schema` setting. See the NetBox plugin GraphQL documentation for details: [GraphQL API](https://netboxlabs.com/docs/netbox/plugins/development/graphql-api/). + +## Test the GraphQL API + +To try out the GraphQL API, open `` in a browser. +In the query editor, enter: + +```graphql +query { + access_list_list { + id + name + rules { + index + description + protocol + action + } + } +} +``` + +You should receive a response showing the ID, name, and rules for each access list. +Try adding or removing fields to see how the response changes. +If you are unsure what fields exist, refer back to the model definitions. + +![GraphiQL interface](/images/step11-graphiql.png) + +For reference, your plugin project should now include the `graphql/` package: + +```text +. +├── netbox_access_lists +│ ├── api +│ │ ├── __init__.py +│ │ ├── serializers.py +│ │ ├── urls.py +│ │ └── views.py +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── graphql +│ │ ├── enums.py +│ │ ├── filters.py +│ │ ├── __init__.py +│ │ ├── schema.py +│ │ └── types.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +
+ +:arrow_left: [Step 10: REST API](/tutorial/step10-rest-api.md) | [Step 12: Search](/tutorial/step12-search.md) :arrow_right: + +
diff --git a/tutorial/step11-search.md b/tutorial/step11-search.md deleted file mode 100644 index 1fefa7f..0000000 --- a/tutorial/step11-search.md +++ /dev/null @@ -1,102 +0,0 @@ -# Step 11: NetBox v3.4 Features - -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. - -: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`. - -:blue_square: **Note:** If you skipped the previous step, run `git checkout step10-graphql`. - -## Create Search Indexes - -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. - -Begin by creating `search.py` in the plugin's root directory, alongside `models.py`. - -```bash -$ cd netbox_access_lists/ -$ edit search.py -``` - -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: - -```python -from netbox.search import SearchIndex -from .models import AccessList, AccessListRule - -class AccessListIndex(SearchIndex): - model = AccessList - -class AccessListRuleIndex(SearchIndex): - model = AccessListRule -``` - -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. - -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. - -* `name`: This is an important field, so we'll give it a high precedence of `100`. -* `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. -* `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. - -After selecting our search fields and their precedences, we should have something like this: - -```python -class AccessListIndex(SearchIndex): - model = AccessList - fields = ( - ('name', 100), - ('comments', 5000), - ) - -class AccessListRuleIndex(SearchIndex): - model = AccessListRule - fields = ( - ('description', 500), - ) -``` - -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. - -## Register the Indexers - -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: - -```python -from netbox.search import SearchIndex, register_search -from .models import AccessList, AccessListRule - -@register_search -class AccessListIndex(SearchIndex): - model = AccessList - fields = ( - ('name', 100), - ('comments', 5000), - ) - -@register_search -class AccessListRuleIndex(SearchIndex): - model = AccessListRule - fields = ( - ('description', 500), - ) -``` - -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.) - -``` -$ ./manage.py reindex netbox_access_lists -Reindexing 2 models. -Indexing models - netbox_access_lists.accesslist... 1 entries cached. - netbox_access_lists.accesslistrule... 3 entries cached. -``` - -Now we can search for access lists and rules using NetBox's global search function. - -![Search results](/images/step11-search-results.png) - -
- -:arrow_left: [Step 10: GraphQL API](/tutorial/step10-graphql-api.md) - -
diff --git a/tutorial/step12-search.md b/tutorial/step12-search.md new file mode 100644 index 0000000..2480775 --- /dev/null +++ b/tutorial/step12-search.md @@ -0,0 +1,247 @@ +# Step 12: Global Search + +In this step, we will add search index classes for our plugin models so they appear in NetBox global search results. + +NetBox global search is meant to help users quickly find objects by typing a few keywords. +Under the hood, NetBox builds a small search index for each model that supports it. +Each index tells NetBox which fields to cache for search and how important each field is. + +:blue_square: **Note:** If you skipped the previous step, run `git checkout step10-graphql` (if you cloned the repository `netbox-plugin-demo`). + +## Create search indexes + +Our plugin has two models: `AccessList` and `AccessListRule`. +We want users to be able to find both via global search. +To do that, we need to declare and register a `SearchIndex` subclass for each model. + +Begin by creating `search.py` in the plugin package root, alongside `models.py`. + +```bash +cd netbox_access_lists/ +touch search.py +``` + +### Define a basic index class per model + +Start by importing `SearchIndex` and our models. +Then create a basic index class for each model: + +```python +from netbox.search import SearchIndex + +from .models import AccessList, AccessListRule + + +class AccessListIndex(SearchIndex): + model = AccessList + + +class AccessListRuleIndex(SearchIndex): + model = AccessListRule +``` + +This tells NetBox which model each index belongs to. +Next, we will tell NetBox which fields should be searchable. + +### Choose fields and weights + +Each index can declare a `fields` attribute, which is a tuple of `(field_name, weight)` pairs. + +Weights control how strongly a match influences ranking. +In general: + +* lower numbers mean higher priority in the results +* higher numbers mean lower priority + +#### AccessList fields + +Our `AccessList` model has these interesting fields: + +* `name` is the primary identifier, so we want it to rank highly +* `default_action` is useful for filtering, but it is not usually something people search for +* `comments` can contain helpful keywords, but matches there should rank lower than matches on `name` + +A reasonable starting point is: + +* `name` with weight `100` +* `comments` with weight `5000` + +```python +class AccessListIndex(SearchIndex): + model = AccessList + fields = ( + ('name', 100), + ('comments', 5000), + ) +``` + +#### AccessListRule fields + +For `AccessListRule`, the most useful free text field is `description`. +We will index that: + +```python +class AccessListRuleIndex(SearchIndex): + model = AccessListRule + fields = ( + ('description', 500), + ) +``` + +You might wonder why we are not indexing source and destination prefixes or ports. + +* Prefixes are related objects. Indexing related values can lead to stale results if the related object changes. +* Ports are common numbers, and indexing them tends to produce noisy results. +* These fields are usually better handled by filters (including the filter set we built in Step 9). + +### Show more context in search results + +Even if we do not index some fields, it can still be helpful to show them in the search result preview. +We can do that using `display_attrs`. + +`display_attrs` controls which attributes are shown to the user alongside the result. +It does not change which fields are searched, it only affects what is displayed. + +Add `display_attrs` like this: + +```python +class AccessListIndex(SearchIndex): + model = AccessList + fields = ( + ('name', 100), + ('comments', 5000), + ) + display_attrs = ('name', 'default_action') + + +class AccessListRuleIndex(SearchIndex): + model = AccessListRule + fields = ( + ('description', 500), + ) + display_attrs = ( + 'access_list', + 'index', + 'protocol', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'action', + 'description', + ) +``` + +## Register the index classes + +To make NetBox aware of our search indexes, we need to register them using the `register_search` decorator. + +Update `search.py` to import `register_search` and decorate both index classes: + +```python +from netbox.search import SearchIndex, register_search + +from .models import AccessList, AccessListRule + + +@register_search +class AccessListIndex(SearchIndex): + model = AccessList + fields = ( + ('name', 100), + ('comments', 5000), + ) + display_attrs = ('name', 'default_action') + + +@register_search +class AccessListRuleIndex(SearchIndex): + model = AccessListRule + fields = ( + ('description', 500), + ) + display_attrs = ( + 'access_list', + 'index', + 'protocol', + 'source_prefix', + 'source_ports', + 'destination_prefix', + 'destination_ports', + 'action', + 'description', + ) +``` + +## Build the search cache + +With the index classes registered, we need to build the search cache for existing objects. +NetBox provides the `reindex` management command for this. + +Run: + +```shell +(venv) $ python netbox/manage.py reindex netbox_access_lists +Reindexing 2 models. +Clearing cached values... 0 entries deleted. +Indexing models + netbox_access_lists.accesslist... 1 entries cached. + netbox_access_lists.accesslistrule... 2 entries cached. +``` + +New objects created after this point are indexed automatically when they are saved. +You will usually only need to run `reindex` again if you change your index definitions or if you want to rebuild the cache for any reason. + +Now try global search in the NetBox UI. +You should be able to find access lists and rules by searching for values in the indexed fields. + +![Search results](/images/step12-search-results.png) + +For reference, your plugin project should now include `search.py`: + +```text +. +├── netbox_access_lists +│ ├── api +│ │ ├── __init__.py +│ │ ├── serializers.py +│ │ ├── urls.py +│ │ └── views.py +│ ├── choices.py +│ ├── filtersets.py +│ ├── forms.py +│ ├── graphql +│ │ ├── enums.py +│ │ ├── filters.py +│ │ ├── __init__.py +│ │ ├── schema.py +│ │ └── types.py +│ ├── __init__.py +│ ├── migrations +│ │ ├── 0001_initial.py +│ │ └── __init__.py +│ ├── models.py +│ ├── navigation.py +│ ├── search.py +│ ├── tables.py +│ ├── templates +│ │ └── netbox_access_lists +│ │ ├── accesslist.html +│ │ └── accesslistrule.html +│ ├── urls.py +│ └── views.py +├── pyproject.toml +└── README.md +``` + +## Conclusion + +This completes the plugin development tutorial. Nice work! + +From here, a good next step is to review the NetBox plugin development documentation and start experimenting with a small plugin idea of your own. + +
+ +:arrow_left: [Step 11: GraphQL API](/tutorial/step11-graphql-api.md) + +