Merge feature

This commit is contained in:
jeremystretch 2022-01-27 16:38:36 -05:00
commit 75aa1c7b80
221 changed files with 5694 additions and 3137 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.4
placeholder: v3.1.6
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.1.4
placeholder: v3.1.6
validations:
required: true
- type: dropdown

View File

@ -82,6 +82,10 @@ markdown-include
# https://github.com/squidfunk/mkdocs-material
mkdocs-material
# Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings
mkdocstrings
# Library for manipulating IP prefixes and addresses
# https://github.com/drkjam/netaddr
netaddr
@ -98,10 +102,6 @@ psycopg2-binary
# https://github.com/yaml/pyyaml
PyYAML
# In-memory key/value store used for caching and queuing
# https://github.com/andymccurdy/redis-py
redis
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core[all]

View File

@ -1,3 +1,4 @@
# Service Mapping
{!models/ipam/servicetemplate.md!}
{!models/ipam/service.md!}

View File

@ -2,7 +2,7 @@
## 1. Define the model class
Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module.
Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be NetBoxModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module.
Each model should define, at a minimum:

View File

@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration"
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.

View File

@ -4,9 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f
## Available Preferences
| Name | Description |
|-------------------------|-------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
| ui.colormode | Light or dark mode in the user interface |
| Name | Description |
|--------------------------|---------------------------------------------------------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) |
| pagination.per_page | The number of items to display per page of a paginated table |
| pagination.placement | Where to display the paginator controls relative to the table |
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
| tables.${table}.ordering | A list of column names by which the table should be ordered |
| ui.colormode | Light or dark mode in the user interface |

View File

@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Application | Django/Python |
| Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
| Live device access | NAPALM (optional) |
## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
## Getting Started
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@ -1,6 +1,6 @@
## Interfaces
Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management).
Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Additionally, each interface may optionally be assigned to a VRF.
!!! note
Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa.

View File

@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* JSON: Arbitrary data stored in JSON format
* Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values
* Object: A single NetBox object of the type defined by `object_type`
* Multiple object: One or more NetBox objects of the type defined by `object_type`
Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.
### Custom Object Fields
An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point.

View File

@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as:
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
```
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually.
!!! warning
Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users.

View File

@ -3,7 +3,7 @@
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
!!! warning
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
## Configuration
@ -12,7 +12,7 @@ A webhook is a mechanism for conveying to some external system a change that too
* **Enabled** - If unchecked, the webhook will be inactive.
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field.
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
@ -23,7 +23,7 @@ A webhook is a mechanism for conveying to some external system a change that too
## Jinja2 Template Support
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:

View File

@ -0,0 +1,3 @@
# Service Templates
Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses.

View File

@ -1,430 +0,0 @@
# Plugin Development
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
Plugins can do a lot, including:
* Create Django models to store data in the database
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Establish their own REST API endpoints
* Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
## Initial Setup
### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
```no-highlight
project-name/
- plugin_name/
- templates/
- plugin_name/
- *.html
- __init__.py
- middleware.py
- navigation.py
- signals.py
- template_content.py
- urls.py
- views.py
- README
- setup.py
```
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
### Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
```python
from setuptools import find_packages, setup
setup(
name='netbox-animal-sounds',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/netbox-community/netbox-animal-sounds',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
from extras.plugins import PluginConfig
class AnimalSoundsConfig(PluginConfig):
name = 'netbox_animal_sounds'
verbose_name = 'Animal Sounds'
description = 'An example plugin for development purposes'
version = '0.1'
author = 'Jeremy Stretch'
author_email = 'author@example.com'
base_url = 'animal-sounds'
required_settings = []
default_settings = {
'loud': False
}
config = AnimalSoundsConfig
```
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
#### PluginConfig Attributes
| Name | Description |
| ---- |---------------------------------------------------------------------------------------------------------------|
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values |
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
```shell
python3 -m venv /path/to/my/venv
```
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
```shell
cd $VENV/lib/python3.8/site-packages/
echo /opt/netbox/netbox > netbox.pth
```
### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
```no-highlight
$ python setup.py develop
```
## Database Models
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
Below is an example `models.py` file containing a model with two character fields:
```python
from django.db import models
class Animal(models.Model):
name = models.CharField(max_length=50)
sound = models.CharField(max_length=50)
def __str__(self):
return self.name
```
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
!!! note
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
```no-highlight
$ ./manage.py makemigrations netbox_animal_sounds
Migrations for 'netbox_animal_sounds':
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
- Create model Animal
```
Next, we can apply the migration to the database with the `migrate` command:
```no-highlight
$ ./manage.py migrate netbox_animal_sounds
Operations to perform:
Apply all migrations: netbox_animal_sounds
Running migrations:
Applying netbox_animal_sounds.0001_initial... OK
```
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
### Using the Django Admin Interface
Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below:
```python
from django.contrib import admin
from .models import Animal
@admin.register(Animal)
class AnimalAdmin(admin.ModelAdmin):
list_display = ('name', 'sound')
```
This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view.
![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png)
## Views
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
```python
from django.shortcuts import render
from django.views.generic import View
from .models import Animal
class RandomAnimalView(View):
"""
Display a randomly-selected animal.
"""
def get(self, request):
animal = Animal.objects.order_by('?').first()
return render(request, 'netbox_animal_sounds/animal.html', {
'animal': animal,
})
```
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
### Extending the Base Template
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
* `title` - The page title
* `header` - The upper portion of the page
* `content` - The main page body
* `javascript` - A section at the end of the page for including Javascript code
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
```jinja2
{% extends 'base/layout.html' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px">
{% if animal %}
The {{ animal.name|lower }} says
{% if config.loud %}
{{ animal.sound|upper }}!
{% else %}
{{ animal.sound }}
{% endif %}
{% else %}
No animals have been created yet!
{% endif %}
</h2>
{% endwith %}
{% endblock %}
```
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
!!! note
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of.
Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
```python
from django.urls import path
from . import views
urlpatterns = [
path('random/', views.RandomAnimalView.as_view(), name='random_animal'),
]
```
A URL pattern has three components:
* `route` - The unique portion of the URL dedicated to this view
* `view` - The view itself
* `name` - A short name used to identify the URL path internally
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
## REST API Endpoints
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
```python
from rest_framework.serializers import ModelSerializer
from netbox_animal_sounds.models import Animal
class AnimalSerializer(ModelSerializer):
class Meta:
model = Animal
fields = ('id', 'name', 'sound')
```
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
```python
from rest_framework.viewsets import ModelViewSet
from netbox_animal_sounds.models import Animal
from .serializers import AnimalSerializer
class AnimalViewSet(ModelViewSet):
queryset = Animal.objects.all()
serializer_class = AnimalSerializer
```
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
```python
from rest_framework import routers
from .views import AnimalViewSet
router = routers.DefaultRouter()
router.register('animals', AnimalViewSet)
urlpatterns = router.urls
```
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png)
!!! warning
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.
## Navigation Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
)
```
A `PluginMenuItem` has the following attributes:
* `link` - The name of the URL path to which this menu item links
* `link_text` - The text presented to the user
* `permissions` - A list of permissions required to display this link (optional)
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
A `PluginMenuButton` has the following attributes:
* `link` - The name of the URL path to which this button links
* `title` - The tooltip text (displayed when the mouse hovers over the button)
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Extending Core Templates
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
* `left_page()` - Inject content on the left side of the page
* `right_page()` - Inject content on the right side of the page
* `full_width_page()` - Inject content across the entire bottom of the page
* `buttons()` - Add buttons to the top of the page
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
* `object` - The object being viewed
* `request` - The current request
* `settings` - Global NetBox settings
* `config` - Plugin-specific configuration parameters
For example, accessing `{{ request.user }}` within a template will return the current user.
Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below.
```python
from extras.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):
model = 'dcim.site'
def right_page(self):
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={
'animal_count': Animal.objects.count(),
})
template_extensions = [SiteAnimalCount]
```
## Background Tasks
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
is below:
```python
class MyPluginConfig(PluginConfig):
name = 'myplugin'
...
queues = [
'queue1',
'queue2',
'queue-whatever-the-name'
]
```
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
```
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
```

View File

@ -0,0 +1,27 @@
# Background Tasks
By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*.
These 3 core queues can be used out-of-the-box by plugins to define background tasks.
Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration
is below:
```python
class MyPluginConfig(PluginConfig):
name = 'myplugin'
...
queues = [
'queue1',
'queue2',
'queue-whatever-the-name'
]
```
The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*.
As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins.
In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks.
```
python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name
```

View File

@ -0,0 +1,56 @@
# Filter Sets
Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets.
## FilterSet Classes
To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below.
```python
# filtersets.py
import django_filters
from netbox.filtersets import NetBoxModelFilterSet
from .models import MyModel
class MyFilterSet(NetBoxModelFilterSet):
status = django_filters.MultipleChoiceFilter(
choices=(
('foo', 'Foo'),
('bar', 'Bar'),
('baz', 'Baz'),
),
null_value=None
)
class Meta:
model = MyModel
fields = ('some', 'other', 'fields')
```
## Declaring Filter Sets
To utilize a filter set in the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as the `filterset` attribute on the view class:
```python
# views.py
from netbox.views.generic import ObjectListView
from .filtersets import MyModelFitlerSet
from .models import MyModel
class MyModelListView(ObjectListView):
queryset = MyModel.objects.all()
filterset = MyModelFitlerSet
```
To enable a filter on a REST API endpoint, set it as the `filterset_class` attribute on the API view:
```python
# api/views.py
from myplugin import models, filtersets
from . import serializers
class MyModelViewSet(...):
queryset = models.MyModel.objects.all()
serializer_class = serializers.MyModelSerializer
filterset_class = filtersets.MyModelFilterSet
```

View File

@ -0,0 +1,146 @@
# Plugins Development
!!! info "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
Plugins can do a lot, including:
* Create Django models to store data in the database
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Establish their own REST API endpoints
* Add custom request/response middleware
However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models.
!!! warning
While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases.
## Initial Setup
### Plugin Structure
Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this:
```no-highlight
project-name/
- plugin_name/
- templates/
- plugin_name/
- *.html
- __init__.py
- middleware.py
- navigation.py
- signals.py
- template_content.py
- urls.py
- views.py
- README
- setup.py
```
The top level is the project root, which can have any name that you like. Immediately within the root should exist several items:
* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment.
* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown.
* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens).
The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class.
### Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:
```python
from setuptools import find_packages, setup
setup(
name='netbox-animal-sounds',
version='0.1',
description='An example NetBox plugin',
url='https://github.com/netbox-community/netbox-animal-sounds',
author='Jeremy Stretch',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
```
Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html).
!!! note
`zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699)
### Define a PluginConfig
The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below:
```python
from extras.plugins import PluginConfig
class AnimalSoundsConfig(PluginConfig):
name = 'netbox_animal_sounds'
verbose_name = 'Animal Sounds'
description = 'An example plugin for development purposes'
version = '0.1'
author = 'Jeremy Stretch'
author_email = 'author@example.com'
base_url = 'animal-sounds'
required_settings = []
default_settings = {
'loud': False
}
config = AnimalSoundsConfig
```
NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors.
#### PluginConfig Attributes
| Name | Description |
| ---- |---------------------------------------------------------------------------------------------------------------|
| `name` | Raw plugin name; same as the plugin's source directory |
| `verbose_name` | Human-friendly name for the plugin |
| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) |
| `description` | Brief description of the plugin's purpose |
| `author` | Name of plugin's author |
| `author_email` | Author's public email address |
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. |
| `required_settings` | A list of any configuration parameters that **must** be defined by the user |
| `default_settings` | A dictionary of configuration parameters and their default values |
| `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum version of NetBox with which the plugin is compatible |
| `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) |
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
### Create a Virtual Environment
It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.)
```shell
python3 -m venv /path/to/my/venv
```
You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.)
```shell
cd $VENV/lib/python3.8/site-packages/
echo /opt/netbox/netbox > netbox.pth
```
### Install the Plugin for Development
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
```no-highlight
$ python setup.py develop
```

View File

@ -0,0 +1,111 @@
# Database Models
## Creating Models
If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`.
Below is an example `models.py` file containing a model with two character fields:
```python
from django.db import models
class Animal(models.Model):
name = models.CharField(max_length=50)
sound = models.CharField(max_length=50)
def __str__(self):
return self.name
```
### Migrations
Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command.
!!! note
A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory.
```no-highlight
$ ./manage.py makemigrations netbox_animal_sounds
Migrations for 'netbox_animal_sounds':
/home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py
- Create model Animal
```
Next, we can apply the migration to the database with the `migrate` command:
```no-highlight
$ ./manage.py migrate netbox_animal_sounds
Operations to perform:
Apply all migrations: netbox_animal_sounds
Running migrations:
Applying netbox_animal_sounds.0001_initial... OK
```
For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/).
## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable numerous feature, including:
* Change logging
* Custom fields
* Custom links
* Custom validation
* Export templates
* Journaling
* Tags
* Webhooks
This class performs two crucial functions:
1. Apply any fields, methods, or attributes necessary to the operation of these features
2. Register the model with NetBox as utilizing these feature
Simply subclass BaseModel when defining a model in your plugin:
```python
# models.py
from django.db import models
from netbox.models import NetBoxModel
class MyModel(NetBoxModel):
foo = models.CharField()
...
```
### Enabling Features Individually
If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (You will also need to inherit from Django's built-in `Model` class.)
```python
# models.py
from django.db import models
from netbox.models.features import ExportTemplatesMixin, TagsMixin
class MyModel(ExportTemplatesMixin, TagsMixin, models.Model):
foo = models.CharField()
...
```
The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.)
## Feature Mixins Reference
!!! note
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins.
::: netbox.models.features.ChangeLoggingMixin
::: netbox.models.features.CustomLinksMixin
::: netbox.models.features.CustomFieldsMixin
::: netbox.models.features.CustomValidationMixin
::: netbox.models.features.ExportTemplatesMixin
::: netbox.models.features.JournalingMixin
::: netbox.models.features.TagsMixin
::: netbox.models.features.WebhooksMixin

View File

@ -0,0 +1,46 @@
# REST API
Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple.
First, we'll create a serializer for our `Animal` model, in `api/serializers.py`:
```python
from rest_framework.serializers import ModelSerializer
from netbox_animal_sounds.models import Animal
class AnimalSerializer(ModelSerializer):
class Meta:
model = Animal
fields = ('id', 'name', 'sound')
```
Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`:
```python
from rest_framework.viewsets import ModelViewSet
from netbox_animal_sounds.models import Animal
from .serializers import AnimalSerializer
class AnimalViewSet(ModelViewSet):
queryset = Animal.objects.all()
serializer_class = AnimalSerializer
```
Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`.
```python
from rest_framework import routers
from .views import AnimalViewSet
router = routers.DefaultRouter()
router.register('animals', AnimalViewSet)
urlpatterns = router.urls
```
With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined.
![NetBox REST API plugin endpoint](../../media/plugins/plugin_rest_api_endpoint.png)
!!! warning
This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have.

View File

@ -0,0 +1,37 @@
# Tables
NetBox employs the [`django-tables2`](https://django-tables2.readthedocs.io/) library for rendering dynamic object tables. These tables display lists of objects, and can be sorted and filtered by various parameters.
## NetBoxTable
To provide additional functionality beyond what is supported by the stock `Table` class in `django-tables2`, NetBox provides the `NetBoxTable` class. This custom table class includes support for:
* User-configurable column display and ordering
* Custom field & custom link columns
* Automatic prefetching of related objects
It also includes several default columns:
* `pk` - A checkbox for selecting the object associated with each table row
* `id` - The object's numeric database ID, as a hyperlink to the object's view
* `actions` - A dropdown menu presenting object-specific actions available to the user.
### Example
```python
# tables.py
import django_tables2 as tables
from netbox.tables import NetBoxTable
from .models import MyModel
class MyModelTable(NetBoxTable):
name = tables.Column(
linkify=True
)
...
class Meta(NetBoxTable.Meta):
model = MyModel
fields = ('pk', 'id', 'name', ...)
default_columns = ('pk', 'name', ...)
```

View File

@ -0,0 +1,254 @@
# Views
If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`:
```python
from django.shortcuts import render
from django.views.generic import View
from .models import Animal
class RandomAnimalView(View):
"""
Display a randomly-selected animal.
"""
def get(self, request):
animal = Animal.objects.order_by('?').first()
return render(request, 'netbox_animal_sounds/animal.html', {
'animal': animal,
})
```
This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below.
## View Classes
NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use.
| View Class | Description |
|------------|-------------|
| `ObjectView` | View a single object |
| `ObjectEditView` | Create or edit a single object |
| `ObjectDeleteView` | Delete a single object |
| `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects |
| `BulkDeleteView` | Delete multiple objects |
!!! warning
Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins.
### Example Usage
```python
# views.py
from netbox.views.generic import ObjectEditView
from .models import Thing
class ThingEditView(ObjectEditView):
queryset = Thing.objects.all()
template_name = 'myplugin/thing.html'
...
```
## URL Registration
To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths.
```python
from django.urls import path
from . import views
urlpatterns = [
path('random/', views.RandomAnimalView.as_view(), name='random_animal'),
]
```
A URL pattern has three components:
* `route` - The unique portion of the URL dedicated to this view
* `view` - The view itself
* `name` - A short name used to identify the URL path internally
This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
## Templates
### Plugin Views
NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks:
* `title` - The page title
* `header` - The upper portion of the page
* `content` - The main page body
* `javascript` - A section at the end of the page for including Javascript code
For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block).
```jinja2
{% extends 'base/layout.html' %}
{% block content %}
{% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %}
<h2 class="text-center" style="margin-top: 200px">
{% if animal %}
The {{ animal.name|lower }} says
{% if config.loud %}
{{ animal.sound|upper }}!
{% else %}
{{ animal.sound }}
{% endif %}
{% else %}
No animals have been created yet!
{% endif %}
</h2>
{% endwith %}
{% endblock %}
```
The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block.
!!! note
Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of.
### Extending Core Views
Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
* `left_page()` - Inject content on the left side of the page
* `right_page()` - Inject content on the right side of the page
* `full_width_page()` - Inject content across the entire bottom of the page
* `buttons()` - Add buttons to the top of the page
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
* `object` - The object being viewed
* `request` - The current request
* `settings` - Global NetBox settings
* `config` - Plugin-specific configuration parameters
For example, accessing `{{ request.user }}` within a template will return the current user.
Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below.
```python
from extras.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):
model = 'dcim.site'
def right_page(self):
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={
'animal_count': Animal.objects.count(),
})
template_extensions = [SiteAnimalCount]
```
## Navigation Menu Items
To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_animal_sounds:random_animal',
link_text='Random sound',
buttons=(
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
)
),
)
```
A `PluginMenuItem` has the following attributes:
* `link` - The name of the URL path to which this menu item links
* `link_text` - The text presented to the user
* `permissions` - A list of permissions required to display this link (optional)
* `buttons` - An iterable of PluginMenuButton instances to display (optional)
A `PluginMenuButton` has the following attributes:
* `link` - The name of the URL path to which this button links
* `title` - The tooltip text (displayed when the mouse hovers over the button)
* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/))
* `color` - One of the choices provided by `ButtonColorChoices` (optional)
* `permissions` - A list of permissions required to display this button (optional)
!!! note
Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons.
## Object Views Reference
Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object.
::: netbox.views.generic.base.BaseObjectView
rendering:
show_source: false
::: netbox.views.generic.ObjectView
selection:
members:
- get_object
- get_template_name
rendering:
show_source: false
::: netbox.views.generic.ObjectEditView
selection:
members:
- get_object
- alter_object
rendering:
show_source: false
::: netbox.views.generic.ObjectDeleteView
selection:
members:
- get_object
rendering:
show_source: false
## Multi-Object Views Reference
Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects.
::: netbox.views.generic.base.BaseMultiObjectView
rendering:
show_source: false
::: netbox.views.generic.ObjectListView
selection:
members:
- get_table
- export_table
- export_template
rendering:
show_source: false
::: netbox.views.generic.BulkImportView
selection:
members: false
rendering:
show_source: false
::: netbox.views.generic.BulkEditView
selection:
members: false
rendering:
show_source: false
::: netbox.views.generic.BulkDeleteView
selection:
members:
- get_form
rendering:
show_source: false

View File

@ -1,6 +1,14 @@
# Release Notes
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
* **Major** - Introduces or removes an entire API or other core functionality
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,6 +1,52 @@
# NetBox v3.1
## v3.1.5 (FUTURE)
## v3.1.7 (FUTURE)
---
## v3.1.6 (2022-01-17)
### Enhancements
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times
### Bug Fixes
* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers
* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form
* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms
---
## v3.1.5 (2022-01-06)
### Enhancements
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
### Bug Fixes
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
---

View File

@ -14,13 +14,15 @@
### New Features
#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658))
#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333))
A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
NetBox's plugins framework has been extended considerably in this release. Changes include:
#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087))
* Seven generic view classes are now officially supported for use by plugins.
* `NetBoxModel` is available for subclassing to enable various NetBox features, such as custom fields and change logging.
* `NetBoxModelFilterSet` is available to extend NetBox's dynamic filtering ability to plugin models.
A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional.
No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized.
#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
@ -28,6 +30,12 @@ Several new models have been added to support field-replaceable device modules,
Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed.
#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006))
Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type.
Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided for each custom field object.
#### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054))
Custom choices can be now added to most status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field:
@ -42,20 +50,40 @@ FIELD_CHOICES = {
}
```
#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087))
A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional.
#### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118))
Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components.
Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device.
#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591))
A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated.
#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658))
A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
### Enhancements
* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators
* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences
* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation
* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables
* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks
* [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form
* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts
* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components
* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs
* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to interface model
* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Webhook URLs can now be templatized
* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links
* [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields
### Other Changes
@ -63,6 +91,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model
* [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model
* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs
* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys
### REST API Changes
@ -73,6 +102,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* `/api/dcim/module-bays/`
* `/api/dcim/module-bay-templates/`
* `/api/dcim/module-types/`
* `/api/extras/service-templates/`
* circuits.ProviderNetwork
* Added `service_id` field
* dcim.ConsolePort
@ -82,7 +112,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* dcim.FrontPort
* Added `module` field
* dcim.Interface
* Added `module` field
* Added `module`, `speed`, `duplex`, and `vrf` fields
* dcim.InventoryItem
* Added `component_type`, `component_id`, and `role` fields
* Added read-only `component` field
@ -96,6 +126,10 @@ Inventory item templates can be arranged hierarchically within a device type, an
* Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
* extras.ConfigContext
* Add `cluster_types` field
* extras.CustomField
* Added `object_type` field
* extras.CustomLink
* Added `enabled` field
* ipam.VLANGroup
* Added the `/availables-vlans/` endpoint
* Added the `min_vid` and `max_vid` fields

View File

@ -1,7 +0,0 @@
# File inclusion plugin for Python-Markdown
# https://github.com/cmacmackin/markdown-include
markdown-include
# MkDocs Material theme (for documentation build)
# https://github.com/squidfunk/mkdocs-material
mkdocs-material

View File

@ -16,6 +16,22 @@ theme:
toggle:
icon: material/lightbulb
name: Switch to Light Mode
plugins:
- mkdocstrings:
handlers:
python:
setup_commands:
- import os
- import django
- os.chdir('netbox/')
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
- django.setup()
rendering:
heading_level: 3
members_order: source
show_root_heading: true
show_root_full_path: false
show_root_toc_entry: false
extra:
social:
- icon: fontawesome/brands/github
@ -84,7 +100,14 @@ nav:
- Webhooks: 'additional-features/webhooks.md'
- Plugins:
- Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md'
- Developing Plugins:
- Getting Started: 'plugins/development/index.md'
- Models: 'plugins/development/models.md'
- Views: 'plugins/development/views.md'
- Tables: 'plugins/development/tables.md'
- Filter Sets: 'plugins/development/filtersets.md'
- REST API: 'plugins/development/rest-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
- Administration:
- Authentication: 'administration/authentication.md'
- Permissions: 'administration/permissions.md'

View File

@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied',
'_occupied', 'created', 'last_updated',
]

View File

@ -3,8 +3,7 @@ from django.db.models import Q
from dcim.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup
from extras.filters import TagFilter
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import *
@ -19,7 +18,7 @@ __all__ = (
)
class ProviderFilterSet(PrimaryModelFilterSet):
class ProviderFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -61,7 +60,6 @@ class ProviderFilterSet(PrimaryModelFilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = TagFilter()
class Meta:
model = Provider
@ -79,7 +77,7 @@ class ProviderFilterSet(PrimaryModelFilterSet):
)
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class ProviderNetworkFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -94,7 +92,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
to_field_name='slug',
label='Provider (slug)',
)
tag = TagFilter()
class Meta:
model = ProviderNetwork
@ -112,14 +109,13 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -190,7 +186,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = TagFilter()
class Meta:
model = Circuit

View File

@ -0,0 +1,44 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0032_provider_service_id'),
]
operations = [
# Model IDs
migrations.AlterField(
model_name='circuit',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='circuittermination',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='circuittype',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='provider',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='providernetwork',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
# GFK IDs
migrations.AlterField(
model_name='circuittermination',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@ -5,8 +5,8 @@ from django.urls import reverse
from circuits.choices import *
from dcim.models import LinkTermination
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
from netbox.models.features import WebhooksMixin
__all__ = (
'Circuit',
@ -15,7 +15,6 @@ __all__ = (
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class CircuitType(OrganizationalModel):
"""
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@ -44,8 +43,7 @@ class CircuitType(OrganizationalModel):
return reverse('circuits:circuittype', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Circuit(PrimaryModel):
class Circuit(NetBoxModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured
@ -138,8 +136,7 @@ class Circuit(PrimaryModel):
return CircuitStatusChoices.colors.get(self.status, 'secondary')
@extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, LinkTermination):
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
@ -212,13 +209,9 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination):
raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
def to_objectchange(self, action):
# Annotate the parent Circuit
try:
circuit = self.circuit
except Circuit.DoesNotExist:
# Parent circuit has been deleted
circuit = None
return super().to_objectchange(action, related_object=circuit)
objectchange = super().to_objectchange(action)
objectchange.related_object = self.circuit
return objectchange
@property
def parent_object(self):

View File

@ -3,8 +3,7 @@ from django.db import models
from django.urls import reverse
from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import PrimaryModel
from netbox.models import NetBoxModel
__all__ = (
'ProviderNetwork',
@ -12,8 +11,7 @@ __all__ = (
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Provider(PrimaryModel):
class Provider(NetBoxModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider.
@ -72,8 +70,7 @@ class Provider(PrimaryModel):
return reverse('circuits:provider', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ProviderNetwork(PrimaryModel):
class ProviderNetwork(NetBoxModel):
"""
This represents a provider network which exists outside of NetBox, the details of which are unknown or
unimportant to the user.

View File

@ -1,11 +1,10 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import *
__all__ = (
'CircuitTable',
'CircuitTypeTable',
@ -23,12 +22,32 @@ CIRCUITTERMINATION_LINK = """
"""
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
#
# Providers
#
class ProviderTable(BaseTable):
pk = ToggleColumn()
class ProviderTable(NetBoxTable):
name = tables.Column(
linkify=True
)
@ -36,16 +55,16 @@ class ProviderTable(BaseTable):
accessor=Accessor('count_circuits'),
verbose_name='Circuits'
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -54,22 +73,23 @@ class ProviderTable(BaseTable):
# Provider networks
#
class ProviderNetworkTable(BaseTable):
pk = ToggleColumn()
class ProviderNetworkTable(NetBoxTable):
name = tables.Column(
linkify=True
)
provider = tables.Column(
linkify=True
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:providernetwork_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = ProviderNetwork
fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags')
fields = (
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
)
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
@ -77,31 +97,30 @@ class ProviderNetworkTable(BaseTable):
# Circuit types
#
class CircuitTypeTable(BaseTable):
pk = ToggleColumn()
class CircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='circuits:circuittype_list'
)
circuit_count = tables.Column(
verbose_name='Circuits'
)
actions = ButtonsColumn(CircuitType)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = CircuitType
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
fields = (
'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
#
# Circuits
#
class CircuitTable(BaseTable):
pk = ToggleColumn()
class CircuitTable(NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name='Circuit ID'
@ -109,7 +128,7 @@ class CircuitTable(BaseTable):
provider = tables.Column(
linkify=True
)
status = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
@ -119,16 +138,17 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
comments = MarkdownColumn()
tags = TagColumn(
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags',
'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@ -5,10 +5,9 @@ from django.shortcuts import get_object_or_404, redirect, render
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table
from netbox.tables import configure_table
from utilities.utils import count_related
from . import filtersets, forms, tables
from .choices import CircuitTerminationSideChoices
from .models import *
@ -35,7 +34,7 @@ class ProviderView(generic.ObjectView):
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
paginate_table(circuits_table, request)
configure_table(circuits_table, request)
return {
'circuits_table': circuits_table,
@ -96,7 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
'type', 'tenant', 'terminations__site'
)
circuits_table = tables.CircuitTable(circuits)
paginate_table(circuits_table, request)
configure_table(circuits_table, request)
return {
'circuits_table': circuits_table,
@ -150,7 +149,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
paginate_table(circuits_table, request)
configure_table(circuits_table, request)
return {
'circuits_table': circuits_table,

View File

@ -6,7 +6,9 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer
from ipam.api.nested_serializers import (
NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer,
)
from ipam.models import ASN, VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import (
@ -219,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta:
model = RackReservation
fields = [
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
'custom_fields',
]
@ -719,6 +721,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
duplex = ChoiceField(choices=InterfaceDuplexChoices, allow_blank=True, required=False)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@ -728,6 +731,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
required=False,
many=True
)
vrf = NestedVRFSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True)
wireless_lans = SerializedPKRelatedField(
@ -743,9 +747,9 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
model = Interface
fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected',
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint',
'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint',
'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
@ -910,7 +914,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields',
'tags', 'custom_fields', 'created', 'last_updated',
]
def _get_termination(self, obj, side):
@ -1004,7 +1008,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
'created', 'last_updated',
]
#
@ -1023,7 +1030,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
fields = [
'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
'created', 'last_updated',
]
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@ -583,7 +583,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer',
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet

View File

@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
TYPE_STACKWISE80 = 'cisco-stackwise-80'
TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
@ -943,6 +951,19 @@ class InterfaceTypeChoices(ChoiceSet):
)
class InterfaceDuplexChoices(ChoiceSet):
DUPLEX_HALF = 'half'
DUPLEX_FULL = 'full'
DUPLEX_AUTO = 'auto'
CHOICES = (
(DUPLEX_HALF, 'Half'),
(DUPLEX_FULL, 'Full'),
(DUPLEX_AUTO, 'Auto'),
)
class InterfaceModeChoices(ChoiceSet):
MODE_ACCESS = 'access'

View File

@ -1,11 +1,10 @@
import django_filters
from django.contrib.auth.models import User
from extras.filters import TagFilter
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import ASN
from ipam.models import ASN, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet,
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
@ -79,7 +78,6 @@ class RegionFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Parent region (slug)',
)
tag = TagFilter()
class Meta:
model = Region
@ -97,14 +95,13 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Parent site group (slug)',
)
tag = TagFilter()
class Meta:
model = SiteGroup
fields = ['id', 'name', 'slug', 'description']
class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -148,7 +145,6 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
queryset=ASN.objects.all(),
label='AS (ID)',
)
tag = TagFilter()
class Meta:
model = Site
@ -225,7 +221,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
to_field_name='slug',
label='Location (slug)',
)
tag = TagFilter()
class Meta:
model = Location
@ -241,14 +236,13 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet):
class RackRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = RackRole
fields = ['id', 'name', 'slug', 'color']
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -325,7 +319,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
tag = TagFilter()
class Meta:
model = Rack
@ -346,7 +339,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
)
class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -389,7 +382,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='username',
label='User (name)',
)
tag = TagFilter()
class Meta:
model = RackReservation
@ -407,14 +399,13 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class ManufacturerFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = Manufacturer
fields = ['id', 'name', 'slug', 'description']
class DeviceTypeFilterSet(PrimaryModelFilterSet):
class DeviceTypeFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -461,7 +452,6 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
method='_device_bays',
label='Has device bays',
)
tag = TagFilter()
class Meta:
model = DeviceType
@ -507,7 +497,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
return queryset.exclude(devicebaytemplates__isnull=value)
class ModuleTypeFilterSet(PrimaryModelFilterSet):
class ModuleTypeFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -546,7 +536,6 @@ class ModuleTypeFilterSet(PrimaryModelFilterSet):
method='_pass_through_ports',
label='Has pass-through ports',
)
tag = TagFilter()
class Meta:
model = ModuleType
@ -732,7 +721,6 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
class DeviceRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = DeviceRole
@ -751,14 +739,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
tag = TagFilter()
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -916,7 +903,6 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
method='_device_bays',
label='Has device bays',
)
tag = TagFilter()
class Meta:
model = Device
@ -970,7 +956,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
return queryset.exclude(devicebays__isnull=value)
class ModuleFilterSet(PrimaryModelFilterSet):
class ModuleFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -990,7 +976,6 @@ class ModuleFilterSet(PrimaryModelFilterSet):
queryset=Device.objects.all(),
label='Device (ID)',
)
tag = TagFilter()
class Meta:
model = Module
@ -1080,7 +1065,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Virtual Chassis',
)
tag = TagFilter()
def search(self, queryset, name, value):
if not value.strip():
@ -1112,7 +1096,7 @@ class PathEndpointFilterSet(django_filters.FilterSet):
return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False))
class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class ConsolePortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@ -1123,7 +1107,7 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl
fields = ['id', 'name', 'label', 'description']
class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=ConsolePortTypeChoices,
null_value=None
@ -1134,7 +1118,7 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet
fields = ['id', 'name', 'label', 'description']
class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerPortTypeChoices,
null_value=None
@ -1145,7 +1129,7 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PowerOutletTypeChoices,
null_value=None
@ -1160,7 +1144,7 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl
fields = ['id', 'name', 'label', 'feed_leg', 'description']
class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1196,9 +1180,12 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
speed = MultiValueNumberFilter()
duplex = django_filters.MultipleChoiceFilter(
choices=InterfaceDuplexChoices
)
mac_address = MultiValueMACAddressFilter()
wwn = MultiValueWWNFilter()
tag = TagFilter()
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
@ -1217,6 +1204,17 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label='VRF',
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF (RD)',
)
class Meta:
model = Interface
@ -1273,7 +1271,7 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
}.get(value, queryset.none())
class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
@ -1284,7 +1282,7 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
fields = ['id', 'name', 'label', 'type', 'color', 'description']
class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet):
type = django_filters.MultipleChoiceFilter(
choices=PortTypeChoices,
null_value=None
@ -1295,21 +1293,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe
fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
class Meta:
model = ModuleBay
fields = ['id', 'name', 'label', 'description']
class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['id', 'name', 'label', 'description']
class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1362,14 +1360,13 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta:
model = InventoryItemRole
fields = ['id', 'name', 'slug', 'color']
class VirtualChassisFilterSet(PrimaryModelFilterSet):
class VirtualChassisFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1432,7 +1429,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = TagFilter()
class Meta:
model = VirtualChassis
@ -1449,7 +1445,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter).distinct()
class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1490,7 +1486,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
method='filter_device',
field_name='device__site__slug'
)
tag = TagFilter()
class Meta:
model = Cable
@ -1509,7 +1504,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
return queryset
class PowerPanelFilterSet(PrimaryModelFilterSet):
class PowerPanelFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1556,7 +1551,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet):
lookup_expr='in',
label='Location (ID)',
)
tag = TagFilter()
class Meta:
model = PowerPanel
@ -1571,7 +1565,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
@ -1626,7 +1620,6 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
choices=PowerFeedStatusChoices,
null_value=None
)
tag = TagFilter()
class Meta:
model = PowerFeed

View File

@ -72,12 +72,12 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']),
form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags',
)

View File

@ -7,11 +7,11 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
from ipam.models import VLAN, ASN
from ipam.models import ASN, VLAN, VRF
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
)
__all__ = (
@ -1028,7 +1028,7 @@ class PowerOutletBulkEditForm(
class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
]),
AddRemoveTagsForm,
@ -1061,7 +1061,13 @@ class InterfaceBulkEditForm(
required=False,
query_params={
'type': 'lag',
}
},
label='LAG'
)
speed = forms.IntegerField(
required=False,
widget=SelectSpeedWidget(attrs={'readonly': None}),
label='Speed'
)
mgmt_only = forms.NullBooleanField(
required=False,
@ -1080,11 +1086,16 @@ class InterfaceBulkEditForm(
queryset=VLAN.objects.all(),
required=False
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
)
class Meta:
nullable_fields = [
'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'vrf',
]
def __init__(self, *args, **kwargs):

View File

@ -8,6 +8,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelCSVForm
from ipam.models import VRF
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
@ -617,11 +618,21 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
choices=InterfaceTypeChoices,
help_text='Physical medium'
)
duplex = CSVChoiceField(
choices=InterfaceDuplexChoices,
required=False
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
)
vrf = CSVModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text='Assigned VRF'
)
rf_role = CSVChoiceField(
choices=WirelessRoleChoices,
required=False,
@ -631,8 +642,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address',
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address',
'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power',
)

View File

@ -6,11 +6,11 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm
from ipam.models import ASN
from ipam.models import ASN, VRF
from tenancy.forms import TenancyFilterForm
from utilities.forms import (
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget,
)
from wireless.choices import *
@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location
field_groups = [
['q'],
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'],
]
@ -678,7 +678,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
field_groups = [
['q', 'tag'],
['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'],
['type', 'status', 'color', 'length', 'length_unit'],
['tenant_group_id', 'tenant_id'],
]
region_id = DynamicModelMultipleChoiceField(
@ -703,6 +703,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'site_id': '$site_id'
}
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
)
type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices),
required=False,
@ -716,15 +726,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
color = ColorField(
required=False
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'tenant_id': '$tenant_id',
'rack_id': '$rack_id',
},
label=_('Device')
length = forms.IntegerField(
required=False
)
length_unit = forms.ChoiceField(
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
tag = TagFilterField(model)
@ -920,7 +927,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
model = Interface
field_groups = [
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'],
['vrf_id', 'mac_address', 'wwn'],
['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
@ -934,6 +942,17 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
widget=StaticSelectMultiple()
)
speed = forms.IntegerField(
required=False,
label='Select Speed',
widget=SelectSpeedWidget(attrs={'readonly': None})
)
duplex = forms.MultipleChoiceField(
choices=InterfaceDuplexChoices,
required=False,
label='Select Duplex',
widget=StaticSelectMultiple()
)
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
@ -980,6 +999,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
min_value=0,
max_value=127
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
)
tag = TagFilterField(model)

View File

@ -9,12 +9,12 @@ from dcim.constants import *
from dcim.models import *
from extras.forms import CustomFieldModelForm
from extras.models import Tag
from ipam.models import IPAddress, VLAN, VLANGroup, ASN
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from tenancy.forms import TenancyForm
from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
SlugField, StaticSelect,
SlugField, StaticSelect, SelectSpeedWidget,
)
from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
@ -1261,6 +1261,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'available_on_device': '$device',
}
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@ -1269,13 +1274,13 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
class Meta:
model = Interface
fields = [
'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu',
'device', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu',
'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
fieldsets = (
('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')),
('Addressing', ('mac_address', 'wwn')),
('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
@ -1287,6 +1292,8 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'duplex': StaticSelect(),
'mode': StaticSelect(),
'rf_role': StaticSelect(),
'rf_channel': StaticSelect(),

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.11 on 2022-01-07 18:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0054_vlangroup_min_max_vids'),
('dcim', '0148_inventoryitem_templates'),
]
operations = [
migrations.AddField(
model_name='interface',
name='vrf',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 3.2.10 on 2022-01-08 18:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0149_interface_vrf'),
]
operations = [
migrations.AddField(
model_name='interface',
name='duplex',
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.AddField(
model_name='interface',
name='speed',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,274 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0150_interface_speed_duplex'),
]
operations = [
# Model IDs
migrations.AlterField(
model_name='cable',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='cablepath',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='consoleport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='consoleserverport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='device',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='devicebay',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='devicerole',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='devicetype',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='frontport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='frontporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='interface',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='interfacetemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='inventoryitem',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='inventoryitemrole',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='inventoryitemtemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='location',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='manufacturer',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='module',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='modulebay',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='modulebaytemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='moduletype',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='platform',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='powerfeed',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='poweroutlet',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='powerpanel',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='powerport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='powerporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rack',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rackreservation',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rackrole',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rearport',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='rearporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='region',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='site',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='sitegroup',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='virtualchassis',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
# GFK IDs
migrations.AlterField(
model_name='cable',
name='termination_a_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='cable',
name='termination_b_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='cablepath',
name='destination_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='cablepath',
name='origin_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='consoleport',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='consoleserverport',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='frontport',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='interface',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='powerfeed',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='poweroutlet',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='powerport',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='rearport',
name='_link_peer_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@ -11,8 +11,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
from extras.utils import extras_features
from netbox.models import BigIDModel, PrimaryModel
from netbox.models import NetBoxModel
from utilities.fields import ColorField
from utilities.utils import to_meters
from .devices import Device
@ -29,8 +28,7 @@ __all__ = (
# Cables
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cable(PrimaryModel):
class Cable(NetBoxModel):
"""
A physical connection between two endpoints.
"""
@ -40,7 +38,7 @@ class Cable(PrimaryModel):
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveIntegerField()
termination_a_id = models.PositiveBigIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
@ -51,7 +49,7 @@ class Cable(PrimaryModel):
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveIntegerField()
termination_b_id = models.PositiveBigIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
@ -300,7 +298,7 @@ class Cable(PrimaryModel):
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
class CablePath(BigIDModel):
class CablePath(models.Model):
"""
A CablePath instance represents the physical path from an origin to a destination, including all intermediate
elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do
@ -329,7 +327,7 @@ class CablePath(BigIDModel):
on_delete=models.CASCADE,
related_name='+'
)
origin_id = models.PositiveIntegerField()
origin_id = models.PositiveBigIntegerField()
origin = GenericForeignKey(
ct_field='origin_type',
fk_field='origin_id'
@ -341,7 +339,7 @@ class CablePath(BigIDModel):
blank=True,
null=True
)
destination_id = models.PositiveIntegerField(
destination_id = models.PositiveBigIntegerField(
blank=True,
null=True
)

View File

@ -1,4 +1,4 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
@ -32,7 +32,7 @@ __all__ = (
)
class ComponentTemplateModel(ChangeLoggedModel):
class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
@ -70,14 +70,10 @@ class ComponentTemplateModel(ChangeLoggedModel):
"""
raise NotImplementedError()
def to_objectchange(self, action, related_object=None):
# Annotate the parent DeviceType
try:
device_type = self.device_type
except ObjectDoesNotExist:
# The parent DeviceType has already been deleted
device_type = None
return super().to_objectchange(action, related_object=device_type)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.device_type
return objectchange
class ModularComponentTemplateModel(ComponentTemplateModel):
@ -102,19 +98,13 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
class Meta:
abstract = True
def to_objectchange(self, action, related_object=None):
# Annotate the parent DeviceType or ModuleType
try:
if getattr(self, 'device_type'):
return super().to_objectchange(action, related_object=self.device_type)
except ObjectDoesNotExist:
pass
try:
if getattr(self, 'module_type'):
return super().to_objectchange(action, related_object=self.module_type)
except ObjectDoesNotExist:
pass
return super().to_objectchange(action)
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
if self.device_type is not None:
objectchange.related_object = self.device_type
elif self.module_type is not None:
objectchange.related_object = self.module_type
return objectchange
def clean(self):
super().clean()
@ -135,7 +125,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return self.name
@extras_features('webhooks')
class ConsolePortTemplate(ModularComponentTemplateModel):
"""
A template for a ConsolePort to be created for a new Device.
@ -164,7 +153,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
"""
A template for a ConsoleServerPort to be created for a new Device.
@ -193,7 +181,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class PowerPortTemplate(ModularComponentTemplateModel):
"""
A template for a PowerPort to be created for a new Device.
@ -245,7 +232,6 @@ class PowerPortTemplate(ModularComponentTemplateModel):
})
@extras_features('webhooks')
class PowerOutletTemplate(ModularComponentTemplateModel):
"""
A template for a PowerOutlet to be created for a new Device.
@ -307,7 +293,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class InterfaceTemplate(ModularComponentTemplateModel):
"""
A template for a physical data interface on a new Device.
@ -347,7 +332,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class FrontPortTemplate(ModularComponentTemplateModel):
"""
Template for a pass-through port on the front of a new Device.
@ -420,7 +404,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class RearPortTemplate(ModularComponentTemplateModel):
"""
Template for a pass-through port on the rear of a new Device.
@ -460,7 +443,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
)
@extras_features('webhooks')
class ModuleBayTemplate(ComponentTemplateModel):
"""
A template for a ModuleBay to be created for a new parent Device.
@ -486,7 +468,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
)
@extras_features('webhooks')
class DeviceBayTemplate(ComponentTemplateModel):
"""
A template for a DeviceBay to be created for a new parent Device.
@ -511,7 +492,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
)
@extras_features('webhooks')
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
"""
A template for an InventoryItem to be created for a new parent Device.

View File

@ -11,8 +11,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG
from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
@ -40,7 +39,7 @@ __all__ = (
)
class ComponentModel(PrimaryModel):
class ComponentModel(NetBoxModel):
"""
An abstract model inherited by any model which has a parent Device.
"""
@ -76,13 +75,9 @@ class ComponentModel(PrimaryModel):
return self.name
def to_objectchange(self, action):
# Annotate the parent Device
try:
device = self.device
except ObjectDoesNotExist:
# The parent Device has already been deleted
device = None
return super().to_objectchange(action, related_object=device)
objectchange = super().to_objectchange(action)
objectchange.related_object = self.device
return super().to_objectchange(action)
@property
def parent_object(self):
@ -131,7 +126,7 @@ class LinkTermination(models.Model):
blank=True,
null=True
)
_link_peer_id = models.PositiveIntegerField(
_link_peer_id = models.PositiveBigIntegerField(
blank=True,
null=True
)
@ -254,7 +249,6 @@ class PathEndpoint(models.Model):
# Console components
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@ -282,7 +276,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@ -314,7 +307,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
# Power components
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@ -407,7 +399,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
}
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@ -522,7 +513,6 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
@ -551,6 +541,16 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
verbose_name='Management only',
help_text='This interface is used only for out-of-band management'
)
speed = models.PositiveIntegerField(
blank=True,
null=True
)
duplex = models.CharField(
max_length=50,
blank=True,
null=True,
choices=InterfaceDuplexChoices
)
wwn = WWNField(
null=True,
blank=True,
@ -616,6 +616,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
blank=True,
verbose_name='Tagged VLANs'
)
vrf = models.ForeignKey(
to='ipam.VRF',
on_delete=models.SET_NULL,
related_name='interfaces',
null=True,
blank=True,
verbose_name='VRF'
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
content_type_field='assigned_object_type',
@ -785,7 +793,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
# Pass-through ports
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ModularComponentModel, LinkTermination):
"""
A pass-through port on the front of a Device.
@ -839,7 +846,6 @@ class FrontPort(ModularComponentModel, LinkTermination):
})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RearPort(ModularComponentModel, LinkTermination):
"""
A pass-through port on the rear of a Device.
@ -883,7 +889,6 @@ class RearPort(ModularComponentModel, LinkTermination):
# Bays
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ModuleBay(ComponentModel):
"""
An empty space within a Device which can house a child device
@ -904,7 +909,6 @@ class ModuleBay(ComponentModel):
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
@ -955,7 +959,6 @@ class DeviceBay(ComponentModel):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItemRole(OrganizationalModel):
"""
Inventory items may optionally be assigned a functional role.
@ -986,7 +989,6 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

View File

@ -13,9 +13,8 @@ from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from .device_components import *
@ -37,7 +36,6 @@ __all__ = (
# Device Types
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@ -70,8 +68,7 @@ class Manufacturer(OrganizationalModel):
return reverse('dcim:manufacturer', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceType(PrimaryModel):
class DeviceType(NetBoxModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
well as high-level functional role(s).
@ -353,8 +350,7 @@ class DeviceType(PrimaryModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ModuleType(PrimaryModel):
class ModuleType(NetBoxModel):
"""
A ModuleType represents a hardware element that can be installed within a device and which houses additional
components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
@ -487,7 +483,6 @@ class ModuleType(PrimaryModel):
# Devices
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel):
"""
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
@ -525,7 +520,6 @@ class DeviceRole(OrganizationalModel):
return reverse('dcim:devicerole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Platform(OrganizationalModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
@ -575,8 +569,7 @@ class Platform(OrganizationalModel):
return reverse('dcim:platform', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Device(PrimaryModel, ConfigContextModel):
class Device(NetBoxModel, ConfigContextModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@ -1012,8 +1005,7 @@ class Device(PrimaryModel, ConfigContextModel):
return DeviceStatusChoices.colors.get(self.status, 'secondary')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Module(PrimaryModel, ConfigContextModel):
class Module(NetBoxModel, ConfigContextModel):
"""
A Module represents a field-installable component within a Device which may itself hold multiple device components
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
@ -1095,8 +1087,7 @@ class Module(PrimaryModel, ConfigContextModel):
# Virtual chassis
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualChassis(PrimaryModel):
class VirtualChassis(NetBoxModel):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
"""

View File

@ -6,8 +6,7 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from extras.utils import extras_features
from netbox.models import PrimaryModel
from netbox.models import NetBoxModel
from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint
@ -21,8 +20,7 @@ __all__ = (
# Power
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPanel(PrimaryModel):
class PowerPanel(NetBoxModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
"""
@ -68,8 +66,7 @@ class PowerPanel(PrimaryModel):
)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
"""
An electrical circuit delivered from a PowerPanel.
"""

View File

@ -13,9 +13,8 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
from extras.utils import extras_features
from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel
from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string
@ -34,7 +33,6 @@ __all__ = (
# Racks
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel):
"""
Racks can be organized by functional role, similar to Devices.
@ -65,8 +63,7 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Rack(PrimaryModel):
class Rack(NetBoxModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
@ -438,8 +435,7 @@ class Rack(PrimaryModel):
return int(allocated_draw_total / available_power_total * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackReservation(PrimaryModel):
class RackReservation(NetBoxModel):
"""
One or more reserved units within a Rack.
"""

View File

@ -7,9 +7,7 @@ from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from netbox.models import NestedGroupModel, NetBoxModel
from utilities.fields import NaturalOrderingField
__all__ = (
@ -24,7 +22,6 @@ __all__ = (
# Regions
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel):
"""
A region represents a geographic collection of sites. For example, you might create regions representing countries,
@ -111,7 +108,6 @@ class Region(NestedGroupModel):
# Site groups
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel):
"""
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@ -198,8 +194,7 @@ class SiteGroup(NestedGroupModel):
# Sites
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Site(PrimaryModel):
class Site(NetBoxModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@ -322,7 +317,6 @@ class Site(PrimaryModel):
# Locations
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel):
"""
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a

View File

@ -19,7 +19,12 @@ __all__ = (
def get_device_name(device):
return device.name or str(device.device_type)
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
else:
return str(device.device_type)
class RackElevationSVG:

View File

@ -1,7 +1,7 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, BooleanColumn
from netbox.tables import BaseTable, columns
from dcim.models import ConsolePort, Interface, PowerPort
from .cables import *
from .devices import *
@ -36,7 +36,7 @@ class ConsoleConnectionTable(BaseTable):
linkify=True,
verbose_name='Console Port'
)
reachable = BooleanColumn(
reachable = columns.BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
@ -44,7 +44,6 @@ class ConsoleConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable')
exclude = ('id', )
class PowerConnectionTable(BaseTable):
@ -67,7 +66,7 @@ class PowerConnectionTable(BaseTable):
linkify=True,
verbose_name='Power Port'
)
reachable = BooleanColumn(
reachable = columns.BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
@ -75,7 +74,6 @@ class PowerConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('device', 'name', 'pdu', 'outlet', 'reachable')
exclude = ('id', )
class InterfaceConnectionTable(BaseTable):
@ -101,7 +99,7 @@ class InterfaceConnectionTable(BaseTable):
linkify=True,
verbose_name='Interface B'
)
reachable = BooleanColumn(
reachable = columns.BooleanColumn(
accessor=Accessor('_path__is_active'),
verbose_name='Reachable'
)
@ -109,4 +107,3 @@ class InterfaceConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable')
exclude = ('id', )

View File

@ -2,8 +2,8 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Cable
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
__all__ = (
@ -15,8 +15,7 @@ __all__ = (
# Cables
#
class CableTable(BaseTable):
pk = ToggleColumn()
class CableTable(NetBoxTable):
termination_a_parent = tables.TemplateColumn(
template_code=CABLE_TERMINATION_PARENT,
accessor=Accessor('termination_a'),
@ -41,22 +40,22 @@ class CableTable(BaseTable):
linkify=True,
verbose_name='Termination B'
)
status = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
length = TemplateColumn(
length = columns.TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
)
color = ColorColumn()
tags = TagColumn(
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:cable_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -5,11 +5,8 @@ from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
)
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
)
from .template_code import *
__all__ = (
@ -74,69 +71,65 @@ def get_interface_state_attribute(record):
# Device roles
#
class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
class DeviceRoleTable(NetBoxTable):
name = tables.Column(
linkify=True
)
device_count = LinkedCountColumn(
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'role_id': 'pk'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
vm_count = columns.LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
url_params={'role_id': 'pk'},
verbose_name='VMs'
)
color = ColorColumn()
vm_role = BooleanColumn()
tags = TagColumn(
color = columns.ColorColumn()
vm_role = columns.BooleanColumn()
tags = columns.TagColumn(
url_name='dcim:devicerole_list'
)
actions = ButtonsColumn(DeviceRole)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = DeviceRole
fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
#
# Platforms
#
class PlatformTable(BaseTable):
pk = ToggleColumn()
class PlatformTable(NetBoxTable):
name = tables.Column(
linkify=True
)
device_count = LinkedCountColumn(
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'platform_id': 'pk'},
verbose_name='Devices'
)
vm_count = LinkedCountColumn(
vm_count = columns.LinkedCountColumn(
viewname='virtualization:virtualmachine_list',
url_params={'platform_id': 'pk'},
verbose_name='VMs'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:platform_list'
)
actions = ButtonsColumn(Platform)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions',
'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
)
@ -144,13 +137,12 @@ class PlatformTable(BaseTable):
# Devices
#
class DeviceTable(BaseTable):
pk = ToggleColumn()
class DeviceTable(NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
site = tables.Column(
linkify=True
@ -161,7 +153,7 @@ class DeviceTable(BaseTable):
rack = tables.Column(
linkify=True
)
device_role = ColoredLabelColumn(
device_role = columns.ColoredLabelColumn(
verbose_name='Role'
)
manufacturer = tables.Column(
@ -197,17 +189,18 @@ class DeviceTable(BaseTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:device_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@ -215,11 +208,11 @@ class DeviceTable(BaseTable):
)
class DeviceImportTable(BaseTable):
class DeviceImportTable(NetBoxTable):
name = tables.TemplateColumn(
template_code=DEVICE_LINK
)
status = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
tenant = TenantColumn()
site = tables.Column(
linkify=True
@ -234,7 +227,7 @@ class DeviceImportTable(BaseTable):
verbose_name='Type'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Device
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False
@ -244,8 +237,7 @@ class DeviceImportTable(BaseTable):
# Device components
#
class DeviceComponentTable(BaseTable):
pk = ToggleColumn()
class DeviceComponentTable(NetBoxTable):
device = tables.Column(
linkify=True
)
@ -254,7 +246,7 @@ class DeviceComponentTable(BaseTable):
order_by=('_name',)
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
order_by = ('device', 'name')
@ -271,26 +263,26 @@ class ModularDeviceComponentTable(DeviceComponentTable):
)
class CableTerminationTable(BaseTable):
class CableTerminationTable(NetBoxTable):
cable = tables.Column(
linkify=True
)
cable_color = ColorColumn(
cable_color = columns.ColorColumn(
accessor='cable.color',
orderable=False,
verbose_name='Cable Color'
)
link_peer = TemplateColumn(
link_peer = columns.TemplateColumn(
accessor='_link_peer',
template_code=LINKTERMINATION,
orderable=False,
verbose_name='Link Peer'
)
mark_connected = BooleanColumn()
mark_connected = columns.BooleanColumn()
class PathEndpointTable(CableTerminationTable):
connection = TemplateColumn(
connection = columns.TemplateColumn(
accessor='_path.last_node',
template_code=LINKTERMINATION,
verbose_name='Connection',
@ -305,7 +297,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
'args': [Accessor('device_id')],
}
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:consoleport_list'
)
@ -313,7 +305,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -324,10 +316,8 @@ class DeviceConsolePortTable(ConsolePortTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=ConsolePort,
buttons=('edit', 'delete'),
prepend_template=CONSOLEPORT_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=CONSOLEPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -336,7 +326,7 @@ class DeviceConsolePortTable(ConsolePortTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
@ -349,7 +339,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
'args': [Accessor('device_id')],
}
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:consoleserverport_list'
)
@ -357,7 +347,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -369,10 +359,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=ConsoleServerPort,
buttons=('edit', 'delete'),
prepend_template=CONSOLESERVERPORT_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=CONSOLESERVERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -381,7 +369,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
row_attrs = {
'class': get_cabletermination_row_class
}
@ -394,7 +382,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
'args': [Accessor('device_id')],
}
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:powerport_list'
)
@ -402,7 +390,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = PowerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -414,10 +403,8 @@ class DevicePowerPortTable(PowerPortTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=PowerPort,
buttons=('edit', 'delete'),
prepend_template=POWERPORT_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=POWERPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -428,7 +415,6 @@ class DevicePowerPortTable(PowerPortTable):
)
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
'actions',
)
row_attrs = {
'class': get_cabletermination_row_class
@ -445,7 +431,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
power_port = tables.Column(
linkify=True
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:poweroutlet_list'
)
@ -453,7 +439,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -464,10 +451,8 @@ class DevicePowerOutletTable(PowerOutletTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=PowerOutlet,
buttons=('edit', 'delete'),
prepend_template=POWEROUTLET_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=POWEROUTLET_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -477,15 +462,15 @@ class DevicePowerOutletTable(PowerOutletTable):
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
)
row_attrs = {
'class': get_cabletermination_row_class
}
class BaseInterfaceTable(BaseTable):
enabled = BooleanColumn()
class BaseInterfaceTable(NetBoxTable):
enabled = columns.BooleanColumn()
ip_addresses = tables.TemplateColumn(
template_code=INTERFACE_IPADDRESSES,
orderable=False,
@ -498,7 +483,7 @@ class BaseInterfaceTable(BaseTable):
verbose_name='FHRP Groups'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = TemplateColumn(
tagged_vlans = columns.TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
orderable=False,
verbose_name='Tagged VLANs'
@ -512,16 +497,19 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'args': [Accessor('device_id')],
}
)
mgmt_only = BooleanColumn()
mgmt_only = columns.BooleanColumn()
wireless_link = tables.Column(
linkify=True
)
wireless_lans = TemplateColumn(
wireless_lans = columns.TemplateColumn(
template_code=INTERFACE_WIRELESS_LANS,
orderable=False,
verbose_name='Wireless LANs'
)
tags = TagColumn(
vrf = tables.Column(
linkify=True
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
@ -529,9 +517,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans',
'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -554,10 +543,8 @@ class DeviceInterfaceTable(InterfaceTable):
linkify=True,
verbose_name='LAG'
)
actions = ButtonsColumn(
model=Interface,
buttons=('edit', 'delete'),
prepend_template=INTERFACE_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=INTERFACE_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -572,7 +559,7 @@ class DeviceInterfaceTable(InterfaceTable):
order_by = ('name',)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection', 'actions',
'cable', 'connection',
)
row_attrs = {
'class': get_interface_row_class,
@ -588,14 +575,14 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
'args': [Accessor('device_id')],
}
)
color = ColorColumn()
color = columns.ColorColumn()
rear_port_position = tables.Column(
verbose_name='Position'
)
rear_port = tables.Column(
linkify=True
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:frontport_list'
)
@ -604,6 +591,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -617,10 +605,8 @@ class DeviceFrontPortTable(FrontPortTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=FrontPort,
buttons=('edit', 'delete'),
prepend_template=FRONTPORT_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=FRONTPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -631,7 +617,6 @@ class DeviceFrontPortTable(FrontPortTable):
)
default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
'actions',
)
row_attrs = {
'class': get_cabletermination_row_class
@ -645,8 +630,8 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
'args': [Accessor('device_id')],
}
)
color = ColorColumn()
tags = TagColumn(
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:rearport_list'
)
@ -654,7 +639,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = RearPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -666,10 +651,8 @@ class DeviceRearPortTable(RearPortTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=RearPort,
buttons=('edit', 'delete'),
prepend_template=REARPORT_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=REARPORT_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -679,7 +662,7 @@ class DeviceRearPortTable(RearPortTable):
'cable', 'cable_color', 'link_peer', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
)
row_attrs = {
'class': get_cabletermination_row_class
@ -700,13 +683,17 @@ class DeviceBayTable(DeviceComponentTable):
installed_device = tables.Column(
linkify=True
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:devicebay_list'
)
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@ -717,10 +704,8 @@ class DeviceDeviceBayTable(DeviceBayTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=DeviceBay,
buttons=('edit', 'delete'),
prepend_template=DEVICEBAY_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=DEVICEBAY_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
@ -728,9 +713,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
)
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
class ModuleBayTable(DeviceComponentTable):
@ -744,7 +727,7 @@ class ModuleBayTable(DeviceComponentTable):
linkify=True,
verbose_name='Installed module'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:modulebay_list'
)
@ -755,16 +738,14 @@ class ModuleBayTable(DeviceComponentTable):
class DeviceModuleBayTable(ModuleBayTable):
actions = ButtonsColumn(
model=DeviceBay,
buttons=('edit', 'delete'),
prepend_template=MODULEBAY_BUTTONS
actions = columns.ActionsColumn(
extra_buttons=MODULEBAY_BUTTONS
)
class Meta(DeviceComponentTable.Meta):
model = ModuleBay
fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions')
default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions')
default_columns = ('pk', 'name', 'label', 'description', 'installed_module')
class InventoryItemTable(DeviceComponentTable):
@ -785,17 +766,17 @@ class InventoryItemTable(DeviceComponentTable):
orderable=False,
linkify=True
)
discovered = BooleanColumn()
tags = TagColumn(
discovered = columns.BooleanColumn()
tags = columns.TagColumn(
url_name='dcim:inventoryitem_list'
)
cable = None # Override DeviceComponentTable
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = InventoryItem
fields = (
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'description', 'discovered', 'tags',
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
@ -809,68 +790,62 @@ class DeviceInventoryItemTable(InventoryItemTable):
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
)
actions = ButtonsColumn(
model=InventoryItem,
buttons=('edit', 'delete')
)
actions = columns.ActionsColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = InventoryItem
fields = (
'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
'description', 'discovered', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions',
'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
)
class InventoryItemRoleTable(BaseTable):
pk = ToggleColumn()
class InventoryItemRoleTable(NetBoxTable):
name = tables.Column(
linkify=True
)
inventoryitem_count = LinkedCountColumn(
inventoryitem_count = columns.LinkedCountColumn(
viewname='dcim:inventoryitem_list',
url_params={'role_id': 'pk'},
verbose_name='Items'
)
color = ColorColumn()
tags = TagColumn(
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:inventoryitemrole_list'
)
actions = ButtonsColumn(InventoryItemRole)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = InventoryItemRole
fields = (
'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions')
default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description')
#
# Virtual chassis
#
class VirtualChassisTable(BaseTable):
pk = ToggleColumn()
class VirtualChassisTable(NetBoxTable):
name = tables.Column(
linkify=True
)
master = tables.Column(
linkify=True
)
member_count = LinkedCountColumn(
member_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'virtual_chassis_id': 'pk'},
verbose_name='Members'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:virtualchassis_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = VirtualChassis
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@ -5,9 +5,7 @@ from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
)
from netbox.tables import NetBoxTable, columns
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
__all__ = (
@ -30,8 +28,7 @@ __all__ = (
# Manufacturers
#
class ManufacturerTable(BaseTable):
pk = ToggleColumn()
class ManufacturerTable(NetBoxTable):
name = tables.Column(
linkify=True
)
@ -45,19 +42,18 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms'
)
slug = tables.Column()
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:manufacturer_list'
)
actions = ButtonsColumn(Manufacturer)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
)
@ -65,30 +61,29 @@ class ManufacturerTable(BaseTable):
# Device types
#
class DeviceTypeTable(BaseTable):
pk = ToggleColumn()
class DeviceTypeTable(NetBoxTable):
model = tables.Column(
linkify=True,
verbose_name='Device Type'
)
is_full_depth = BooleanColumn(
is_full_depth = columns.BooleanColumn(
verbose_name='Full Depth'
)
instance_count = LinkedCountColumn(
instance_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'device_type_id': 'pk'},
verbose_name='Instances'
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:devicetype_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = DeviceType
fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'airflow', 'comments', 'instance_count', 'tags',
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
@ -99,8 +94,7 @@ class DeviceTypeTable(BaseTable):
# Device type components
#
class ComponentTemplateTable(BaseTable):
pk = ToggleColumn()
class ComponentTemplateTable(NetBoxTable):
id = tables.Column(
verbose_name='ID'
)
@ -108,15 +102,14 @@ class ComponentTemplateTable(BaseTable):
order_by=('_name',)
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
exclude = ('id', )
class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsolePortTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -126,10 +119,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ConsoleServerPortTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -139,10 +131,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerPortTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -152,10 +143,9 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=PowerOutletTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -165,13 +155,12 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
class InterfaceTemplateTable(ComponentTemplateTable):
mgmt_only = BooleanColumn(
mgmt_only = columns.BooleanColumn(
verbose_name='Management Only'
)
actions = ButtonsColumn(
model=InterfaceTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -184,11 +173,10 @@ class FrontPortTemplateTable(ComponentTemplateTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
color = ColorColumn()
actions = ButtonsColumn(
model=FrontPortTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
color = columns.ColorColumn()
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -198,11 +186,10 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class RearPortTemplateTable(ComponentTemplateTable):
color = ColorColumn()
actions = ButtonsColumn(
model=RearPortTemplate,
buttons=('edit', 'delete'),
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
color = columns.ColorColumn()
actions = columns.ActionsColumn(
sequence=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
)
class Meta(ComponentTemplateTable.Meta):
@ -212,9 +199,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
class ModuleBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=ModuleBayTemplate,
buttons=('edit', 'delete')
actions = columns.ActionsColumn(
sequence=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@ -224,9 +210,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=DeviceBayTemplate,
buttons=('edit', 'delete')
actions = columns.ActionsColumn(
sequence=('edit', 'delete')
)
class Meta(ComponentTemplateTable.Meta):
@ -236,9 +221,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
class InventoryItemTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn(
model=InventoryItemTemplate,
buttons=('edit', 'delete')
actions = columns.ActionsColumn(
sequence=('edit', 'delete')
)
role = tables.Column(
linkify=True

View File

@ -1,7 +1,7 @@
import django_tables2 as tables
from dcim.models import Module, ModuleType
from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
from netbox.tables import NetBoxTable, columns
__all__ = (
'ModuleTable',
@ -9,23 +9,22 @@ __all__ = (
)
class ModuleTypeTable(BaseTable):
pk = ToggleColumn()
class ModuleTypeTable(NetBoxTable):
model = tables.Column(
linkify=True,
verbose_name='Module Type'
)
instance_count = LinkedCountColumn(
instance_count = columns.LinkedCountColumn(
viewname='dcim:module_list',
url_params={'module_type_id': 'pk'},
verbose_name='Instances'
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags',
@ -35,8 +34,7 @@ class ModuleTypeTable(BaseTable):
)
class ModuleTable(BaseTable):
pk = ToggleColumn()
class ModuleTable(NetBoxTable):
device = tables.Column(
linkify=True
)
@ -46,12 +44,12 @@ class ModuleTable(BaseTable):
module_type = tables.Column(
linkify=True
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:module_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',

View File

@ -1,7 +1,7 @@
import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
from netbox.tables import NetBoxTable, columns
from .devices import CableTerminationTable
__all__ = (
@ -14,26 +14,25 @@ __all__ = (
# Power panels
#
class PowerPanelTable(BaseTable):
pk = ToggleColumn()
class PowerPanelTable(NetBoxTable):
name = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True
)
powerfeed_count = LinkedCountColumn(
powerfeed_count = columns.LinkedCountColumn(
viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:powerpanel_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@ -44,7 +43,6 @@ class PowerPanelTable(BaseTable):
# We're not using PathEndpointTable for PowerFeed because power connections
# cannot traverse pass-through ports.
class PowerFeedTable(CableTerminationTable):
pk = ToggleColumn()
name = tables.Column(
linkify=True
)
@ -54,25 +52,25 @@ class PowerFeedTable(CableTerminationTable):
rack = tables.Column(
linkify=True
)
status = ChoiceFieldColumn()
type = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
type = columns.ChoiceFieldColumn()
max_utilization = tables.TemplateColumn(
template_code="{{ value }}%"
)
available_power = tables.Column(
verbose_name='Available power (VA)'
)
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:powerfeed_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = PowerFeed
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -2,11 +2,8 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
TagColumn, ToggleColumn, UtilizationColumn,
)
__all__ = (
'RackTable',
@ -19,28 +16,28 @@ __all__ = (
# Rack roles
#
class RackRoleTable(BaseTable):
pk = ToggleColumn()
class RackRoleTable(NetBoxTable):
name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks')
color = ColorColumn()
tags = TagColumn(
color = columns.ColorColumn()
tags = columns.TagColumn(
url_name='dcim:rackrole_list'
)
actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = RackRole
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
fields = (
'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
#
# Racks
#
class RackTable(BaseTable):
pk = ToggleColumn()
class RackTable(NetBoxTable):
name = tables.Column(
order_by=('_name',),
linkify=True
@ -52,27 +49,27 @@ class RackTable(BaseTable):
linkify=True
)
tenant = TenantColumn()
status = ChoiceFieldColumn()
role = ColoredLabelColumn()
status = columns.ChoiceFieldColumn()
role = columns.ColoredLabelColumn()
u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U",
verbose_name='Height'
)
comments = MarkdownColumn()
device_count = LinkedCountColumn(
comments = columns.MarkdownColumn()
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'rack_id': 'pk'},
verbose_name='Devices'
)
get_utilization = UtilizationColumn(
get_utilization = columns.UtilizationColumn(
orderable=False,
verbose_name='Space'
)
get_power_utilization = UtilizationColumn(
get_power_utilization = columns.UtilizationColumn(
orderable=False,
verbose_name='Power'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
outer_width = tables.TemplateColumn(
@ -84,11 +81,12 @@ class RackTable(BaseTable):
verbose_name='Outer Depth'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@ -100,8 +98,7 @@ class RackTable(BaseTable):
# Rack reservations
#
class RackReservationTable(BaseTable):
pk = ToggleColumn()
class RackReservationTable(NetBoxTable):
reservation = tables.Column(
accessor='pk',
linkify=True
@ -118,17 +115,14 @@ class RackReservationTable(BaseTable):
orderable=False,
verbose_name='Units'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:rackreservation_list'
)
actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

View File

@ -1,11 +1,9 @@
import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
)
from .template_code import LOCATION_ELEVATIONS
from .template_code import LOCATION_BUTTONS
__all__ = (
'LocationTable',
@ -19,85 +17,85 @@ __all__ = (
# Regions
#
class RegionTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
class RegionTable(NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
site_count = LinkedCountColumn(
site_count = columns.LinkedCountColumn(
viewname='dcim:site_list',
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:region_list'
)
actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Region
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
#
# Site groups
#
class SiteGroupTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
class SiteGroupTable(NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
site_count = LinkedCountColumn(
site_count = columns.LinkedCountColumn(
viewname='dcim:site_list',
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
actions = ButtonsColumn(SiteGroup)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = SiteGroup
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
#
# Sites
#
class SiteTable(BaseTable):
pk = ToggleColumn()
class SiteTable(NetBoxTable):
name = tables.Column(
linkify=True
)
status = ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
region = tables.Column(
linkify=True
)
group = tables.Column(
linkify=True
)
asn_count = LinkedCountColumn(
asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns.count'),
viewname='ipam:asn_list',
url_params={'site_id': 'pk'},
verbose_name='ASNs'
)
tenant = TenantColumn()
comments = MarkdownColumn()
tags = TagColumn(
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:site_list'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@ -106,37 +104,35 @@ class SiteTable(BaseTable):
# Locations
#
class LocationTable(BaseTable):
pk = ToggleColumn()
name = MPTTColumn(
class LocationTable(NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
site = tables.Column(
linkify=True
)
tenant = TenantColumn()
rack_count = LinkedCountColumn(
rack_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'location_id': 'pk'},
verbose_name='Racks'
)
device_count = LinkedCountColumn(
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
tags = TagColumn(
tags = columns.TagColumn(
url_name='dcim:location_list'
)
actions = ButtonsColumn(
model=Location,
prepend_template=LOCATION_ELEVATIONS
actions = columns.ActionsColumn(
extra_buttons=LOCATION_BUTTONS
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')

View File

@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
"""
LOCATION_ELEVATIONS = """
LOCATION_BUTTONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>
@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
{% load helpers %}
{% if perms.dcim.add_invnetoryitemtemplate %}
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
{% if perms.dcim.add_inventoryitemtemplate %}
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}

View File

@ -6,7 +6,7 @@ from rest_framework import status
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN
from ipam.models import ASN, RIR, VLAN, VRF
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType
from wireless.models import WirelessLAN
@ -1424,6 +1424,13 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
)
WirelessLAN.objects.bulk_create(wireless_lans)
vrfs = (
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
)
VRF.objects.bulk_create(vrfs)
cls.create_data = [
{
'device': device.pk,
@ -1431,9 +1438,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'vrf': vrfs[0].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'speed': 1000000,
'duplex': 'full'
},
{
'device': device.pk,
@ -1442,9 +1452,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'mode': InterfaceModeChoices.MODE_TAGGED,
'bridge': interfaces[0].pk,
'tx_power': 10,
'vrf': vrfs[1].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'speed': 100000,
'duplex': 'half'
},
{
'device': device.pk,
@ -1453,6 +1466,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[1].pk,
'tx_power': 10,
'vrf': vrfs[2].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],

View File

@ -4,7 +4,7 @@ from django.test import TestCase
from dcim.choices import *
from dcim.filtersets import *
from dcim.models import *
from ipam.models import ASN, IPAddress, RIR
from ipam.models import ASN, IPAddress, RIR, VRF
from tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
@ -2370,16 +2370,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
vrfs = (
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
VRF(name='VRF 3', rd='65000:3'),
)
VRF.objects.bulk_create(vrfs)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'),
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'),
Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'),
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40),
Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
@ -2416,6 +2423,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_speed(self):
params = {'speed': [1000000, 100000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_duplex(self):
params = {'duplex': ['half', 'full']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mgmt_only(self):
params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@ -2550,6 +2565,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tx_power': [40]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_vrf(self):
vrfs = VRF.objects.all()[:2]
params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all()

View File

@ -11,7 +11,7 @@ from netaddr import EUI
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN
from ipam.models import ASN, RIR, VLAN, VRF
from tenancy.models import Tenant
from utilities.testing import ViewTestCases, create_tags, create_test_device
from wireless.models import WirelessLAN
@ -2105,6 +2105,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
WirelessLAN.objects.bulk_create(wireless_lans)
vrfs = (
VRF(name='VRF 1'),
VRF(name='VRF 2'),
VRF(name='VRF 3'),
)
VRF.objects.bulk_create(vrfs)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
@ -2117,6 +2124,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 65000,
'speed': 1000000,
'duplex': 'full',
'mgmt_only': True,
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
@ -2124,6 +2133,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'vrf': vrfs[0].pk,
'tags': [t.pk for t in tags],
}
@ -2137,12 +2147,15 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 2000,
'speed': 100000,
'duplex': 'half',
'mgmt_only': True,
'description': 'A front port',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'vrf': vrfs[0].pk,
'tags': [t.pk for t in tags],
}
@ -2153,19 +2166,22 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'mac_address': EUI('01:02:03:04:05:06'),
'wwn': EUI('01:02:03:04:05:06:07:08', version=64),
'mtu': 2000,
'speed': 1000000,
'duplex': 'full',
'mgmt_only': True,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'vrf': vrfs[1].pk,
}
cls.csv_data = (
"device,name,type",
"Device 1,Interface 4,1000base-t",
"Device 1,Interface 5,1000base-t",
"Device 1,Interface 6,1000base-t",
f"device,name,type,vrf.pk",
f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}",
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])

View File

@ -20,7 +20,7 @@ from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table
from netbox.tables import configure_table
from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
from virtualization.models import VirtualMachine
@ -165,7 +165,7 @@ class RegionView(generic.ObjectView):
region=instance
)
sites_table = tables.SiteTable(sites, exclude=('region',))
paginate_table(sites_table, request)
configure_table(sites_table, request)
return {
'child_regions_table': child_regions_table,
@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView):
group=instance
)
sites_table = tables.SiteTable(sites, exclude=('group',))
paginate_table(sites_table, request)
configure_table(sites_table, request)
return {
'child_groups_table': child_groups_table,
@ -422,7 +422,7 @@ class LocationView(generic.ObjectView):
cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations)
paginate_table(child_locations_table, request)
configure_table(child_locations_table, request)
return {
'rack_count': rack_count,
@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView):
)
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
paginate_table(racks_table, request)
configure_table(racks_table, request)
return {
'racks_table': racks_table,
@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView):
)
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
paginate_table(devicetypes_table, request)
configure_table(devicetypes_table, request)
return {
'devicetypes_table': devicetypes_table,
@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView):
device_role=instance
)
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
paginate_table(devices_table, request)
configure_table(devices_table, request)
return {
'devices_table': devices_table,
@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView):
platform=instance
)
devices_table = tables.DeviceTable(devices, exclude=('platform',))
paginate_table(devices_table, request)
configure_table(devices_table, request)
return {
'devices_table': devices_table,
@ -2379,8 +2379,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
return_url = self.get_return_url(request)
return redirect('dcim:device', pk=device_bay.device.pk)
return redirect(return_url)
return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay,

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
@ -44,9 +45,20 @@ class CustomFieldsDataField(Field):
return self._custom_fields
def to_representation(self, obj):
return {
cf.name: obj.get(cf.name) for cf in self._get_custom_fields()
}
# TODO: Fix circular import
from utilities.api import get_serializer_for_model
data = {}
for cf in self._get_custom_fields():
value = cf.deserialize(obj.get(cf.name))
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
value = serializer(value, context=self.parent.context).data
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
value = serializer(value, many=True, context=self.parent.context).data
data[cf.name] = value
return data
def to_internal_value(self, data):
# If updating an existing instance, start with existing custom_field_data

View File

@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'conditions', 'ssl_verification', 'ca_file_path',
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
]
@ -79,14 +79,28 @@ class CustomFieldSerializer(ValidatedModelSerializer):
)
type = ChoiceField(choices=CustomFieldTypeChoices)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
class Meta:
model = CustomField
fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
'choices', 'created', 'last_updated',
]
def get_data_type(self, obj):
types = CustomFieldTypeChoices
if obj.type == types.TYPE_INTEGER:
return 'integer'
if obj.type == types.TYPE_BOOLEAN:
return 'boolean'
if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):
return 'object'
if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT):
return 'array'
return 'string'
#
# Custom links
@ -101,8 +115,8 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class Meta:
model = CustomLink
fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated',
]
@ -120,7 +134,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment',
'file_extension', 'as_attachment', 'created', 'last_updated',
]
@ -134,7 +148,9 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
]
#

View File

@ -16,6 +16,8 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_JSON = 'json'
TYPE_SELECT = 'select'
TYPE_MULTISELECT = 'multiselect'
TYPE_OBJECT = 'object'
TYPE_MULTIOBJECT = 'multiobject'
CHOICES = (
(TYPE_TEXT, 'Text'),
@ -27,6 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_JSON, 'JSON'),
(TYPE_SELECT, 'Selection'),
(TYPE_MULTISELECT, 'Multiple selection'),
(TYPE_OBJECT, 'Object'),
(TYPE_MULTIOBJECT, 'Multiple objects'),
)

View File

@ -7,6 +7,7 @@ EXTRAS_FEATURES = [
'custom_links',
'export_templates',
'job_results',
'journaling',
'tags',
'webhooks'
]

View File

@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet):
class Meta:
model = CustomLink
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
fields = [
'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
]
def search(self, queryset, name, value):
if not value.strip():

View File

@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm):
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
new_window = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()

View File

@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm):
class Meta:
model = CustomLink
fields = (
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
'link_url',
)

View File

@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import *
from extras.models import *
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
__all__ = (
'CustomFieldModelCSVForm',
@ -20,7 +20,7 @@ class CustomFieldsMixin:
Extend a Form to include custom field support.
"""
def __init__(self, *args, **kwargs):
self.custom_fields = []
self.custom_fields = {}
super().__init__(*args, **kwargs)
@ -34,6 +34,9 @@ class CustomFieldsMixin:
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type)
def _get_form_field(self, customfield):
return customfield.to_form_field()
@ -41,15 +44,12 @@ class CustomFieldsMixin:
"""
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for customfield in CustomField.objects.filter(content_types=content_type):
for customfield in self._get_custom_fields(self._get_content_type()):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
self.custom_fields[field_name] = customfield
class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
def clean(self):
# Save custom field data on instance
for cf_name in self.custom_fields:
for cf_name, customfield in self.custom_fields.items():
key = cf_name[3:] # Strip "cf_" from field name
value = self.cleaned_data.get(cf_name)
empty_values = self.fields[cf_name].empty_values
# Convert "empty" values to null
self.instance.custom_field_data[key] = value if value not in empty_values else None
if value in self.fields[cf_name].empty_values:
self.instance.custom_field_data[key] = None
else:
self.instance.custom_field_data[key] = customfield.serialize(value)
return super().clean()
@ -86,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm):
class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields:
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
for customfield in self._get_custom_fields(self._get_content_type()):
# Annotate non-required custom fields as nullable
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(cf.name)
if not customfield.required:
self.nullable_fields.append(customfield.name)
self.fields[customfield.name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields[customfield.name] = customfield
class CustomFieldModelFilterForm(FilterForm):
class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
for cf in custom_fields:
field_name = f'cf_{cf.name}'
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)

View File

@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm):
class CustomLinkFilterForm(FilterForm):
field_groups = [
['q'],
['content_type', 'weight', 'new_window'],
['content_type', 'enabled', 'new_window', 'weight'],
]
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
required=False
)
weight = forms.IntegerField(
required=False
enabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
new_window = forms.NullBooleanField(
required=False,
@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
weight = forms.IntegerField(
required=False
)
class ExportTemplateFilterForm(FilterForm):

View File

@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -35,12 +35,16 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
model = CustomField
fields = '__all__'
fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')),
('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
)
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@ -53,10 +57,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
model = CustomLink
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
('Templates', ('link_text', 'link_url')),
)
widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
}
@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)
@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook
fields = '__all__'
fieldsets = (
('Webhook', ('name', 'enabled')),
('Assigned Models', ('content_types',)),
('Webhook', ('name', 'content_types', 'enabled')),
('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')),
)
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
}

View File

@ -0,0 +1,18 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0068_configcontext_cluster_types'),
]
operations = [
migrations.AddField(
model_name='customfield',
name='object_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.11 on 2022-01-10 16:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0069_custom_object_field'),
]
operations = [
migrations.AddField(
model_name='customlink',
name='enabled',
field=models.BooleanField(default=True),
),
]

View File

@ -0,0 +1,89 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0070_customlink_enabled'),
]
operations = [
# Model IDs
migrations.AlterField(
model_name='configcontext',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='configrevision',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='customfield',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='customlink',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='exporttemplate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='imageattachment',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='jobresult',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='journalentry',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='objectchange',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='taggeditem',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='webhook',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
# GFK IDs
migrations.AlterField(
model_name='imageattachment',
name='object_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='journalentry',
name='assigned_object_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='objectchange',
name='changed_object_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='objectchange',
name='related_object_id',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@ -5,11 +5,10 @@ from django.db import models
from django.urls import reverse
from extras.choices import *
from netbox.models import BigIDModel
from utilities.querysets import RestrictedQuerySet
class ObjectChange(BigIDModel):
class ObjectChange(models.Model):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
@ -43,7 +42,7 @@ class ObjectChange(BigIDModel):
on_delete=models.PROTECT,
related_name='+'
)
changed_object_id = models.PositiveIntegerField()
changed_object_id = models.PositiveBigIntegerField()
changed_object = GenericForeignKey(
ct_field='changed_object_type',
fk_field='changed_object_id'
@ -55,7 +54,7 @@ class ObjectChange(BigIDModel):
blank=True,
null=True
)
related_object_id = models.PositiveIntegerField(
related_object_id = models.PositiveBigIntegerField(
blank=True,
null=True
)

View File

@ -5,8 +5,8 @@ from django.db import models
from django.urls import reverse
from extras.querysets import ConfigContextQuerySet
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.utils import deepmerge
@ -20,8 +20,7 @@ __all__ = (
# Config contexts
#
@extras_features('webhooks')
class ConfigContext(ChangeLoggedModel):
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B

View File

@ -12,11 +12,13 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from extras.choices import *
from extras.utils import FeatureQuery, extras_features
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities import filters
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -39,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
@extras_features('webhooks', 'export_templates')
class CustomField(ChangeLoggedModel):
class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
@ -50,7 +51,15 @@ class CustomField(ChangeLoggedModel):
type = models.CharField(
max_length=50,
choices=CustomFieldTypeChoices,
default=CustomFieldTypeChoices.TYPE_TEXT
default=CustomFieldTypeChoices.TYPE_TEXT,
help_text='The type of data this custom field holds'
)
object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
blank=True,
null=True,
help_text='The type of NetBox object this field maps to (for object fields)'
)
name = models.CharField(
max_length=50,
@ -122,7 +131,6 @@ class CustomField(ChangeLoggedModel):
null=True,
help_text='Comma-separated list of available choices (for selection fields)'
)
objects = CustomFieldManager()
class Meta:
@ -234,11 +242,48 @@ class CustomField(ChangeLoggedModel):
'default': f"The specified default value ({self.default}) is not listed as an available choice."
})
# Object fields must define an object_type; other fields must not
if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT):
if not self.object_type:
raise ValidationError({
'object_type': "Object fields must define an object type."
})
elif self.object_type:
raise ValidationError({
'object_type': f"{self.get_type_display()} fields may not define an object type."
})
def serialize(self, value):
"""
Prepare a value for storage as JSON data.
"""
if value is None:
return value
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
return value.pk
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
return [obj.pk for obj in value] or None
return value
def deserialize(self, value):
"""
Convert JSON data to a Python object suitable for the field type.
"""
if value is None:
return value
if self.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = self.object_type.model_class()
return model.objects.filter(pk=value).first()
if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
model = self.object_type.model_class()
return model.objects.filter(pk__in=value)
return value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
@ -287,7 +332,7 @@ class CustomField(ChangeLoggedModel):
choices=choices, required=required, initial=initial, widget=StaticSelect()
)
else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
)
@ -300,6 +345,24 @@ class CustomField(ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = forms.JSONField(required=required, initial=initial)
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = self.object_type.model_class()
field = DynamicModelChoiceField(
queryset=model.objects.all(),
required=required,
initial=initial
)
# Multiple objects
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
model = self.object_type.model_class()
field = DynamicModelMultipleChoiceField(
queryset=model.objects.all(),
required=required,
initial=initial
)
# Text
else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:

View File

@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
from extras.constants import *
from extras.conditions import ConditionSet
from extras.utils import extras_features, FeatureQuery, image_upload
from netbox.models import BigIDModel, ChangeLoggedModel
from extras.utils import FeatureQuery, image_upload
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@ -35,8 +36,7 @@ __all__ = (
)
@extras_features('webhooks', 'export_templates')
class Webhook(ChangeLoggedModel):
class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
@ -68,7 +68,8 @@ class Webhook(ChangeLoggedModel):
payload_url = models.CharField(
max_length=500,
verbose_name='URL',
help_text="A POST will be sent to this URL when the webhook is called."
help_text='This URL will be called using the HTTP method defined when the webhook is called. '
'Jinja2 template processing is supported with the same context as the request body.'
)
enabled = models.BooleanField(
default=True
@ -176,9 +177,14 @@ class Webhook(ChangeLoggedModel):
else:
return json.dumps(context, cls=JSONEncoder)
def render_payload_url(self, context):
"""
Render the payload URL.
"""
return render_jinja2(self.payload_url, context)
@extras_features('webhooks', 'export_templates')
class CustomLink(ChangeLoggedModel):
class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
@ -192,6 +198,9 @@ class CustomLink(ChangeLoggedModel):
max_length=100,
unique=True
)
enabled = models.BooleanField(
default=True
)
link_text = models.CharField(
max_length=500,
help_text="Jinja2 template code for link text"
@ -248,8 +257,7 @@ class CustomLink(ChangeLoggedModel):
}
@extras_features('webhooks', 'export_templates')
class ExportTemplate(ChangeLoggedModel):
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
@ -335,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel):
return response
@extras_features('webhooks')
class ImageAttachment(ChangeLoggedModel):
class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
"""
An uploaded image which is associated with an object.
"""
@ -344,7 +351,7 @@ class ImageAttachment(ChangeLoggedModel):
to=ContentType,
on_delete=models.CASCADE
)
object_id = models.PositiveIntegerField()
object_id = models.PositiveBigIntegerField()
parent = GenericForeignKey(
ct_field='content_type',
fk_field='object_id'
@ -411,11 +418,12 @@ class ImageAttachment(ChangeLoggedModel):
return None
def to_objectchange(self, action):
return super().to_objectchange(action, related_object=self.parent)
objectchange = super().to_objectchange(action)
objectchange.related_object = self.parent
return objectchange
@extras_features('webhooks')
class JournalEntry(ChangeLoggedModel):
class JournalEntry(WebhooksMixin, ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@ -425,7 +433,7 @@ class JournalEntry(ChangeLoggedModel):
to=ContentType,
on_delete=models.CASCADE
)
assigned_object_id = models.PositiveIntegerField()
assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
@ -461,7 +469,7 @@ class JournalEntry(ChangeLoggedModel):
return JournalEntryKindChoices.colors.get(self.kind)
class JobResult(BigIDModel):
class JobResult(models.Model):
"""
This model stores the results from running a user-defined report.
"""
@ -593,8 +601,7 @@ class ConfigRevision(models.Model):
# Custom scripts & reports
#
@extras_features('job_results')
class Script(models.Model):
class Script(JobResultsMixin, models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
@ -606,8 +613,7 @@ class Script(models.Model):
# Reports
#
@extras_features('job_results')
class Report(models.Model):
class Report(JobResultsMixin, models.Model):
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""

View File

@ -3,8 +3,8 @@ from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField
@ -13,8 +13,10 @@ from utilities.fields import ColorField
# Tags
#
@extras_features('webhooks', 'export_templates')
class Tag(ChangeLoggedModel, TagBase):
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField(
primary_key=True
)
color = ColorField(
default=ColorChoices.COLOR_GREY
)
@ -37,7 +39,7 @@ class Tag(ChangeLoggedModel, TagBase):
return slug
class TaggedItem(BigIDModel, GenericTaggedItemBase):
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",

View File

@ -1,3 +1,8 @@
import collections
from extras.constants import EXTRAS_FEATURES
class Registry(dict):
"""
Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or
@ -7,15 +12,19 @@ class Registry(dict):
try:
return super().__getitem__(key)
except KeyError:
raise KeyError("Invalid store: {}".format(key))
raise KeyError(f"Invalid store: {key}")
def __setitem__(self, key, value):
if key in self:
raise KeyError("Store already set: {}".format(key))
raise KeyError(f"Store already set: {key}")
super().__setitem__(key, value)
def __delitem__(self, key):
raise TypeError("Cannot delete stores from registry")
# Initialize the global registry
registry = Registry()
registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
}

View File

@ -21,7 +21,7 @@ from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
# Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar):
class MultiChoiceVar(ScriptVariable):
"""
Like ChoiceVar, but allows for the selection of multiple choices.
"""
form_field = forms.MultipleChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
class ObjectVar(ScriptVariable):
"""

View File

@ -1,10 +1,7 @@
import django_tables2 as tables
from django.conf import settings
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
MarkdownColumn, ToggleColumn,
)
from netbox.tables import NetBoxTable, columns
from .models import *
__all__ = (
@ -46,19 +43,18 @@ OBJECTCHANGE_REQUEST_ID = """
# Custom fields
#
class CustomFieldTable(BaseTable):
pk = ToggleColumn()
class CustomFieldTable(NetBoxTable):
name = tables.Column(
linkify=True
)
content_types = ContentTypesColumn()
required = BooleanColumn()
content_types = columns.ContentTypesColumn()
required = columns.BooleanColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices',
'description', 'filter_logic', 'choices', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
@ -67,39 +63,39 @@ class CustomFieldTable(BaseTable):
# Custom links
#
class CustomLinkTable(BaseTable):
pk = ToggleColumn()
class CustomLinkTable(NetBoxTable):
name = tables.Column(
linkify=True
)
content_type = ContentTypeColumn()
new_window = BooleanColumn()
content_type = columns.ContentTypeColumn()
enabled = columns.BooleanColumn()
new_window = columns.BooleanColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = CustomLink
fields = (
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window')
#
# Export templates
#
class ExportTemplateTable(BaseTable):
pk = ToggleColumn()
class ExportTemplateTable(NetBoxTable):
name = tables.Column(
linkify=True
)
content_type = ContentTypeColumn()
as_attachment = BooleanColumn()
content_type = columns.ContentTypeColumn()
as_attachment = columns.BooleanColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = ExportTemplate
fields = (
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@ -110,31 +106,30 @@ class ExportTemplateTable(BaseTable):
# Webhooks
#
class WebhookTable(BaseTable):
pk = ToggleColumn()
class WebhookTable(NetBoxTable):
name = tables.Column(
linkify=True
)
content_types = ContentTypesColumn()
enabled = BooleanColumn()
type_create = BooleanColumn(
content_types = columns.ContentTypesColumn()
enabled = columns.BooleanColumn()
type_create = columns.BooleanColumn(
verbose_name='Create'
)
type_update = BooleanColumn(
type_update = columns.BooleanColumn(
verbose_name='Update'
)
type_delete = BooleanColumn(
type_delete = columns.BooleanColumn(
verbose_name='Delete'
)
ssl_validation = BooleanColumn(
ssl_validation = columns.BooleanColumn(
verbose_name='SSL Validation'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Webhook
fields = (
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
@ -146,27 +141,25 @@ class WebhookTable(BaseTable):
# Tags
#
class TagTable(BaseTable):
pk = ToggleColumn()
class TagTable(NetBoxTable):
name = tables.Column(
linkify=True
)
color = ColorColumn()
actions = ButtonsColumn(Tag)
color = columns.ColorColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = Tag
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions')
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
class TaggedItemTable(BaseTable):
class TaggedItemTable(NetBoxTable):
id = tables.Column(
verbose_name='ID',
linkify=lambda record: record.content_object.get_absolute_url(),
accessor='content_object__id'
)
content_type = ContentTypeColumn(
content_type = columns.ContentTypeColumn(
verbose_name='Type'
)
content_object = tables.Column(
@ -175,36 +168,36 @@ class TaggedItemTable(BaseTable):
verbose_name='Object'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = TaggedItem
fields = ('id', 'content_type', 'content_object')
class ConfigContextTable(BaseTable):
pk = ToggleColumn()
class ConfigContextTable(NetBoxTable):
name = tables.Column(
linkify=True
)
is_active = BooleanColumn(
is_active = columns.BooleanColumn(
verbose_name='Active'
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable):
class ObjectChangeTable(NetBoxTable):
time = tables.DateTimeColumn(
linkify=True,
format=settings.SHORT_DATETIME_FORMAT
)
action = ChoiceFieldColumn()
changed_object_type = ContentTypeColumn(
action = columns.ChoiceFieldColumn()
changed_object_type = columns.ContentTypeColumn(
verbose_name='Type'
)
object_repr = tables.TemplateColumn(
@ -215,13 +208,14 @@ class ObjectChangeTable(BaseTable):
template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name='Request ID'
)
actions = columns.ActionsColumn(sequence=())
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = ObjectChange
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class ObjectJournalTable(BaseTable):
class ObjectJournalTable(NetBoxTable):
"""
Used for displaying a set of JournalEntries within the context of a single object.
"""
@ -229,22 +223,18 @@ class ObjectJournalTable(BaseTable):
linkify=True,
format=settings.SHORT_DATETIME_FORMAT
)
kind = ChoiceFieldColumn()
kind = columns.ChoiceFieldColumn()
comments = tables.TemplateColumn(
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
)
actions = ButtonsColumn(
model=JournalEntry
)
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = JournalEntry
fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions')
class JournalEntryTable(ObjectJournalTable):
pk = ToggleColumn()
assigned_object_type = ContentTypeColumn(
assigned_object_type = columns.ContentTypeColumn(
verbose_name='Object type'
)
assigned_object = tables.Column(
@ -252,15 +242,14 @@ class JournalEntryTable(ObjectJournalTable):
orderable=False,
verbose_name='Object'
)
comments = MarkdownColumn()
comments = columns.MarkdownColumn()
class Meta(BaseTable.Meta):
class Meta(NetBoxTable.Meta):
model = JournalEntry
fields = (
'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
'comments', 'actions'
)
default_columns = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
'comments', 'actions'
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
)

View File

@ -36,7 +36,7 @@ def custom_links(context, obj):
Render all applicable links for the given object.
"""
content_type = ContentType.objects.get_for_model(obj)
custom_links = CustomLink.objects.filter(content_type=content_type)
custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
if not custom_links:
return ''

View File

@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
{
'content_type': 'dcim.site',
'name': 'Custom Link 4',
'enabled': True,
'link_text': 'Link 4',
'link_url': 'http://example.com/?4',
},
{
'content_type': 'dcim.site',
'name': 'Custom Link 5',
'enabled': True,
'link_text': 'Link 5',
'link_url': 'http://example.com/?5',
},
{
'content_type': 'dcim.site',
'name': 'Custom Link 6',
'enabled': False,
'link_text': 'Link 6',
'link_url': 'http://example.com/?6',
},
]
bulk_update_data = {
'new_window': True,
'enabled': False,
}
@classmethod
@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
CustomLink(
content_type=site_ct,
name='Custom Link 1',
enabled=True,
link_text='Link 1',
link_url='http://example.com/?1',
),
CustomLink(
content_type=site_ct,
name='Custom Link 2',
enabled=True,
link_text='Link 2',
link_url='http://example.com/?2',
),
CustomLink(
content_type=site_ct,
name='Custom Link 3',
enabled=False,
link_text='Link 3',
link_url='http://example.com/?3',
),

View File

@ -8,13 +8,15 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site, Rack
from extras.choices import *
from extras.models import CustomField
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
class CustomFieldTest(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
Site.objects.bulk_create([
Site(name='Site A', slug='site-a'),
@ -22,137 +24,294 @@ class CustomFieldTest(TestCase):
Site(name='Site C', slug='site-c'),
])
def test_simple_fields(self):
DATA = (
{
'field': {
'type': CustomFieldTypeChoices.TYPE_TEXT,
},
'value': 'Foobar!',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_LONGTEXT,
},
'value': 'Text with **Markdown**',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
},
'value': 0,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': 1,
'validation_maximum': 100,
},
'value': 42,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_INTEGER,
'validation_minimum': -100,
'validation_maximum': -1,
},
'value': -42,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': True,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_BOOLEAN,
},
'value': False,
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_DATE,
},
'value': '2016-06-23',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_URL,
},
'value': 'http://example.com/',
},
{
'field': {
'type': CustomFieldTypeChoices.TYPE_JSON,
},
'value': '{"foo": 1, "bar": 2}',
},
cls.object_type = ContentType.objects.get_for_model(Site)
def test_text_field(self):
value = 'Foobar!'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='text_field',
type=CustomFieldTypeChoices.TYPE_TEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
obj_type = ContentType.objects.get_for_model(Site)
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
for data in DATA:
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Create a custom field
cf = CustomField(name='my_field', required=False, **data['field'])
cf.save()
cf.content_types.set([obj_type])
def test_longtext_field(self):
value = 'A' * 256
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='longtext_field',
type=CustomFieldTypeChoices.TYPE_LONGTEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = data['value']
site.save()
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], data['value'])
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
def test_integer_field(self):
# Delete the custom field
cf.delete()
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='integer_field',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
for value in (123456, 0, -123456):
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_boolean_field(self):
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='boolean_field',
type=CustomFieldTypeChoices.TYPE_INTEGER,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
for value in (True, False):
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_date_field(self):
value = '2016-06-23'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='date_field',
type=CustomFieldTypeChoices.TYPE_TEXT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_url_field(self):
value = 'http://example.com/'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='url_field',
type=CustomFieldTypeChoices.TYPE_URL,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_json_field(self):
value = '{"foo": 1, "bar": 2}'
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='json_field',
type=CustomFieldTypeChoices.TYPE_JSON,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site)
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
# Create a custom field
cf = CustomField(
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field',
required=False,
choices=['Option A', 'Option B', 'Option C']
choices=CHOICES
)
cf.save()
cf.content_types.set([obj_type])
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Assign a value to the first Site
site.custom_field_data[cf.name] = 'Option A'
site.save()
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
def test_multiselect_field(self):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='multiselect_field',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
required=False,
choices=CHOICES
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Delete the custom field
cf.delete()
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_object_field(self):
value = VLAN.objects.create(name='VLAN 1', vid=1).pk
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='object_field',
type=CustomFieldTypeChoices.TYPE_OBJECT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiobject_field(self):
vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
)
VLAN.objects.bulk_create(vlans)
value = [vlan.pk for vlan in vlans]
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='object_field',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
required=False
)
cf.content_types.set([self.object_type])
instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Assign a value and check that it is saved
instance.custom_field_data[cf.name] = value
instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Delete the stored value and check that it is now null
instance.custom_field_data.pop(cf.name)
instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_rename_customfield(self):
obj_type = ContentType.objects.get_for_model(Site)
@ -201,76 +360,116 @@ class CustomFieldAPITest(APITestCase):
def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site)
# Text custom field
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
cls.cf_text.save()
cls.cf_text.content_types.set([content_type])
# Create some VLANs
vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
VLAN(name='VLAN 3', vid=3),
VLAN(name='VLAN 4', vid=4),
VLAN(name='VLAN 5', vid=5),
)
VLAN.objects.bulk_create(vlans)
# Long text custom field
cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC')
cls.cf_longtext.save()
cls.cf_longtext.content_types.set([content_type])
custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False),
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
choices=(
'Foo', 'Bar', 'Baz'
)
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
choices=(
'Foo', 'Bar', 'Baz'
)
),
CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT,
name='object_field',
object_type=ContentType.objects.get_for_model(VLAN),
default=vlans[0].pk,
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
name='multiobject_field',
object_type=ContentType.objects.get_for_model(VLAN),
default=[vlans[0].pk, vlans[1].pk],
),
)
for cf in custom_fields:
cf.save()
cf.content_types.set([content_type])
# Integer custom field
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123)
cls.cf_integer.save()
cls.cf_integer.content_types.set([content_type])
# Boolean custom field
cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False)
cls.cf_boolean.save()
cls.cf_boolean.content_types.set([content_type])
# Date custom field
cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01')
cls.cf_date.save()
cls.cf_date.content_types.set([content_type])
# URL custom field
cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1')
cls.cf_url.save()
cls.cf_url.content_types.set([content_type])
# JSON custom field
cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}')
cls.cf_json.save()
cls.cf_json.content_types.set([content_type])
# Select custom field
cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz'])
cls.cf_select.default = 'Foo'
cls.cf_select.save()
cls.cf_select.content_types.set([content_type])
# Create some sites
cls.sites = (
# Create some sites *after* creating the custom fields. This ensures that
# default values are not set for the assigned objects.
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(cls.sites)
Site.objects.bulk_create(sites)
# Assign custom field values for site 2
cls.sites[1].custom_field_data = {
cls.cf_text.name: 'bar',
cls.cf_longtext.name: 'DEF',
cls.cf_integer.name: 456,
cls.cf_boolean.name: True,
cls.cf_date.name: '2020-01-02',
cls.cf_url.name: 'http://example.com/2',
cls.cf_json.name: '{"foo": 1, "bar": 2}',
cls.cf_select.name: 'Bar',
sites[1].custom_field_data = {
custom_fields[0].name: 'bar',
custom_fields[1].name: 'DEF',
custom_fields[2].name: 456,
custom_fields[3].name: True,
custom_fields[4].name: '2020-01-02',
custom_fields[5].name: 'http://example.com/2',
custom_fields[6].name: '{"foo": 1, "bar": 2}',
custom_fields[7].name: 'Bar',
custom_fields[8].name: ['Bar', 'Baz'],
custom_fields[9].name: vlans[1].pk,
custom_fields[10].name: [vlans[2].pk, vlans[3].pk],
}
cls.sites[1].save()
sites[1].save()
def test_get_custom_fields(self):
TYPES = {
CustomFieldTypeChoices.TYPE_TEXT: 'string',
CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
CustomFieldTypeChoices.TYPE_DATE: 'string',
CustomFieldTypeChoices.TYPE_URL: 'string',
CustomFieldTypeChoices.TYPE_JSON: 'object',
CustomFieldTypeChoices.TYPE_SELECT: 'string',
CustomFieldTypeChoices.TYPE_MULTISELECT: 'array',
CustomFieldTypeChoices.TYPE_OBJECT: 'object',
CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array',
}
self.add_permissions('extras.view_customfield')
url = reverse('extras-api:customfield-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], len(TYPES))
# Validate data types
for customfield in response.data['results']:
cf_type = customfield['type']['value']
self.assertEqual(customfield['data_type'], TYPES[cf_type])
def test_get_single_object_without_custom_field_data(self):
"""
Validate that custom fields are present on an object even if it has no values defined.
"""
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
site1 = Site.objects.get(name='Site 1')
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[0].name)
self.assertEqual(response.data['name'], site1.name)
self.assertEqual(response.data['custom_fields'], {
'text_field': None,
'longtext_field': None,
@ -279,19 +478,23 @@ class CustomFieldAPITest(APITestCase):
'date_field': None,
'url_field': None,
'json_field': None,
'choice_field': None,
'select_field': None,
'multiselect_field': None,
'object_field': None,
'multiobject_field': None,
})
def test_get_single_object_with_custom_field_data(self):
"""
Validate that custom fields are present and correctly set for an object with values defined.
"""
site2_cfvs = self.sites[1].custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
site2_cfvs = site2.custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[1].name)
self.assertEqual(response.data['name'], site2.name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@ -299,12 +502,21 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field'])
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field'])
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field'])
self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'])
self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
site2_cfvs['multiobject_field']
)
def test_create_single_object_with_defaults(self):
"""
Create a new site with no specified custom field values and check that it received the default values.
"""
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = {
'name': 'Site 3',
'slug': 'site-3',
@ -317,25 +529,34 @@ class CustomFieldAPITest(APITestCase):
# Validate response data
response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['json_field'], self.cf_json.default)
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
cf_defaults['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
def test_create_single_object_with_values(self):
"""
@ -352,7 +573,10 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
},
}
url = reverse('dcim-api:site-list')
@ -371,7 +595,13 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
self.assertEqual(response_cf['json_field'], data_cf['json_field'])
self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
self.assertEqual(response_cf['select_field'], data_cf['select_field'])
self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field'])
self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
data_cf['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data['id'])
@ -382,13 +612,19 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field'])
self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
def test_create_multiple_objects_with_defaults(self):
"""
Create three news sites with no specified custom field values and check that each received
Create three new sites with no specified custom field values and check that each received
the default custom field values.
"""
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = (
{
'name': 'Site 3',
@ -414,25 +650,34 @@ class CustomFieldAPITest(APITestCase):
# Validate response data
response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default)
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default)
self.assertEqual(response_cf['number_field'], self.cf_integer.default)
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default)
self.assertEqual(response_cf['date_field'], self.cf_date.default)
self.assertEqual(response_cf['url_field'], self.cf_url.default)
self.assertEqual(response_cf['json_field'], self.cf_json.default)
self.assertEqual(response_cf['choice_field'], self.cf_select.default)
self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['select_field'], cf_defaults['select_field'])
self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field'])
self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
cf_defaults['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default)
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default)
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default)
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default)
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default)
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default)
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default)
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default)
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field'])
self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field'])
self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
def test_create_multiple_objects_with_values(self):
"""
@ -446,7 +691,10 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02',
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'choice_field': 'Bar',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}
data = (
{
@ -483,7 +731,13 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field'])
self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
custom_field_data['multiobject_field']
)
# Validate database data
site = Site.objects.get(pk=response.data[i]['id'])
@ -494,22 +748,25 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field'])
self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
def test_update_single_object_with_values(self):
"""
Update an object with existing custom field values. Ensure that only the updated custom field values are
modified.
"""
site = self.sites[1]
original_cfvs = {**site.custom_field_data}
site2 = Site.objects.get(name='Site 2')
original_cfvs = {**site2.custom_field_data}
data = {
'custom_fields': {
'text_field': 'ABCD',
'number_field': 1234,
},
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
@ -524,26 +781,37 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field'])
self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'])
self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']],
original_cfvs['multiobject_field']
)
# Validate database data
site.refresh_from_db()
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field'])
self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field'])
site2.refresh_from_db()
self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field'])
self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field'])
self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field'])
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
def test_minimum_maximum_values_validation(self):
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
self.cf_integer.validation_minimum = 10
self.cf_integer.validation_maximum = 20
self.cf_integer.save()
cf_integer = CustomField.objects.get(name='number_field')
cf_integer.validation_minimum = 10
cf_integer.validation_maximum = 20
cf_integer.save()
data = {'custom_fields': {'number_field': 9}}
response = self.client.patch(url, data, format='json', **self.header)
@ -558,11 +826,13 @@ class CustomFieldAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK)
def test_regex_validation(self):
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site')
self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
self.cf_text.save()
cf_text = CustomField.objects.get(name='text_field')
cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
cf_text.save()
data = {'custom_fields': {'text_field': 'ABC123'}}
response = self.client.patch(url, data, format='json', **self.header)
@ -597,6 +867,9 @@ class CustomFieldImportTest(TestCase):
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
)
for cf in custom_fields:
cf.save()
@ -607,19 +880,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField.
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1
site1 = Site.objects.get(name='Site 1')
self.assertEqual(len(site1.custom_field_data), 8)
self.assertEqual(len(site1.custom_field_data), 9)
self.assertEqual(site1.custom_field_data['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123)
@ -628,10 +902,11 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
self.assertEqual(len(site2.custom_field_data), 8)
self.assertEqual(len(site2.custom_field_data), 9)
self.assertEqual(site2.custom_field_data['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456)
@ -640,6 +915,7 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')

View File

@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink(
name='Custom Link 1',
content_type=content_types[0],
enabled=True,
weight=100,
new_window=False,
link_text='Link 1',
@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink(
name='Custom Link 2',
content_type=content_types[1],
enabled=True,
weight=200,
new_window=False,
link_text='Link 1',
@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink(
name='Custom Link 3',
content_type=content_types[2],
enabled=False,
weight=300,
new_window=True,
link_text='Link 1',
@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enabled(self):
params = {'enabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_new_window(self):
params = {'new_window': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -38,10 +38,27 @@ class CustomFieldModelFormTest(TestCase):
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES)
cf_multiselect = CustomField.objects.create(
name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
)
cf_multiselect.content_types.set([obj_type])
cf_object = CustomField.objects.create(
name='object',
type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(Site)
)
cf_object.content_types.set([obj_type])
cf_multiobject = CustomField.objects.create(
name='multiobject',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(Site)
)
cf_multiobject.content_types.set([obj_type])
def test_empty_values(self):
"""
Test that empty custom field values are stored as null

View File

@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create((
CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'),
CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'),
CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'),
CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
))
cls.form_data = {
'name': 'Custom Link X',
'content_type': site_ct.pk,
'enabled': False,
'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT,
'link_text': 'Link X',
@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"name,content_type,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6",
"name,content_type,enabled,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
)
cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CYAN,
'enabled': False,
'weight': 200,
}

View File

@ -1,5 +1,3 @@
import collections
from django.db.models import Q
from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager
@ -57,21 +55,9 @@ class FeatureQuery:
return query
def extras_features(*features):
"""
Decorator used to register extras provided features to a model
"""
def wrapper(model_class):
# Initialize the model_features store if not already defined
if 'model_features' not in registry:
registry['model_features'] = {
f: collections.defaultdict(list) for f in EXTRAS_FEATURES
}
for feature in features:
if feature in EXTRAS_FEATURES:
app_label, model_name = model_class._meta.label_lower.split('.')
registry['model_features'][feature][app_label].append(model_name)
else:
raise ValueError('{} is not a valid extras feature!'.format(feature))
return model_class
return wrapper
def register_features(model, features):
for feature in features:
if feature not in EXTRAS_FEATURES:
raise ValueError(f"{feature} is not a valid extras feature!")
app_label, model_name = model._meta.label_lower.split('.')
registry['model_features'][feature][app_label].add(model_name)

View File

@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx
from utilities.tables import paginate_table
from netbox.tables import configure_table
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
@ -215,7 +215,7 @@ class TagView(generic.ObjectView):
data=tagged_items,
orderable=False
)
paginate_table(taggeditem_table, request)
configure_table(taggeditem_table, request)
object_types = [
{
@ -451,7 +451,7 @@ class ObjectChangeLogView(View):
data=objectchanges,
orderable=False
)
paginate_table(objectchanges_table, request)
configure_table(objectchanges_table, request)
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html.
@ -571,7 +571,7 @@ class ObjectJournalView(View):
assigned_object_id=obj.pk
)
journalentry_table = tables.ObjectJournalTable(journalentries)
paginate_table(journalentry_table, request)
configure_table(journalentry_table, request)
if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm(

View File

@ -67,7 +67,7 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
# Prepare the HTTP request
params = {
'method': webhook.http_method,
'url': webhook.payload_url,
'url': webhook.render_payload_url(context),
'headers': headers,
'data': body.encode('utf8'),
}

View File

@ -15,6 +15,7 @@ __all__ = [
'NestedRoleSerializer',
'NestedRouteTargetSerializer',
'NestedServiceSerializer',
'NestedServiceTemplateSerializer',
'NestedVLANGroupSerializer',
'NestedVLANSerializer',
'NestedVRFSerializer',
@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
# Services
#
class NestedServiceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
class Meta:
model = models.ServiceTemplate
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')

View File

@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceTemplateSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
class Meta:
model = ServiceTemplate
fields = [
'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created',
'last_updated',
]
class ServiceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True)

View File

@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet)
router.register('vlans', views.VLANViewSet)
# Services
router.register('service-templates', views.ServiceTemplateViewSet)
router.register('services', views.ServiceViewSet)
app_name = 'ipam-api'

Some files were not shown because too many files have changed in this diff Show More