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:
-
+
-### 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.
-
-
-
-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.)
-
-
-
-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 %}
-
-
-
-
-
-
-
- | 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`).
-
-
-
-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:
-
-```
-
-
-
-
-
- {% 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.
-
-
-
-## 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 |
-
- {{ object.access_list }}
- |
-
-
- | Index |
- {{ object.index }} |
-
-
- | Description |
- {{ object.description|placeholder }} |
-
-
-
-
- {% include 'inc/panels/custom_fields.html' %}
- {% include 'inc/panels/tags.html' %}
-
-
-
-
-
-
-
- | 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.
-
-
-
-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.
+
+
+
+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:
+
+
+
+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`).
-
-
-
-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.
-
-
-
-
-
-: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 %}
+
+
+
+
+
+
+ | 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.
+
+
+
+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
+
+
+
+
+
+ {% render_table rules_table %}
+
+
+
+
+```
+
+Refresh the access list detail page.
+You should now see the rules table at the bottom:
+
+
+
+### 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:
+
+
+
+## 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 |
+ {{ object.access_list|linkify }} |
+
+
+ | Index |
+ {{ object.index }} |
+
+
+ | Description |
+ {{ object.description|placeholder }} |
+
+
+
+ {% include 'inc/panels/custom_fields.html' %}
+ {% include 'inc/panels/tags.html' %}
+
+
+
+
+
+
+ | 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:
+
+
+
+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.
-
-
-
-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.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+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`).
-
-
-
-
-
-
-
-: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.)
-
-
-
-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.
+
+
+
+
+
+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.
+
+
+
+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.
-
-
-
-
-
-: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.
+
+
+
+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)
+
+