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: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.4 placeholder: v3.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
## 1. Define the model class ## 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: 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" !!! 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. 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 ## 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. 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

@ -5,8 +5,10 @@ The `users.UserConfig` model holds individual preferences for each user in the f
## Available Preferences ## Available Preferences
| Name | Description | | Name | Description |
|-------------------------|-------------| |--------------------------|---------------------------------------------------------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) | | 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.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}.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 | | 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 | | Application | Django/Python |
| Database | PostgreSQL 10+ | | Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM (optional) |
## Supported Python Versions ## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
## Getting Started ## 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
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 !!! 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. 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 * JSON: Arbitrary data stored in JSON format
* Selection: A selection of one of several pre-defined custom choices * Selection: A selection of one of several pre-defined custom choices
* Multiple selection: A selection field which supports the assignment of multiple values * 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. 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. 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. 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> <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 !!! 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. 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. 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 !!! 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 ## 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. * **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. * **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`. * **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`) * **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). * **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.) * **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 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: 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 # 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) #### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,6 +1,52 @@
# NetBox v3.1 # 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 ### 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)) #### 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. 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 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: 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 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 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. 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 ### 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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 ### 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 * [#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 * [#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 * [#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 ### 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-bays/`
* `/api/dcim/module-bay-templates/` * `/api/dcim/module-bay-templates/`
* `/api/dcim/module-types/` * `/api/dcim/module-types/`
* `/api/extras/service-templates/`
* circuits.ProviderNetwork * circuits.ProviderNetwork
* Added `service_id` field * Added `service_id` field
* dcim.ConsolePort * dcim.ConsolePort
@ -82,7 +112,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* dcim.FrontPort * dcim.FrontPort
* Added `module` field * Added `module` field
* dcim.Interface * dcim.Interface
* Added `module` field * Added `module`, `speed`, `duplex`, and `vrf` fields
* dcim.InventoryItem * dcim.InventoryItem
* Added `component_type`, `component_id`, and `role` fields * Added `component_type`, `component_id`, and `role` fields
* Added read-only `component` field * 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 * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields
* extras.ConfigContext * extras.ConfigContext
* Add `cluster_types` field * Add `cluster_types` field
* extras.CustomField
* Added `object_type` field
* extras.CustomLink
* Added `enabled` field
* ipam.VLANGroup * ipam.VLANGroup
* Added the `/availables-vlans/` endpoint * Added the `/availables-vlans/` endpoint
* Added the `min_vid` and `max_vid` fields * 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: toggle:
icon: material/lightbulb icon: material/lightbulb
name: Switch to Light Mode 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: extra:
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
@ -84,7 +100,14 @@ nav:
- Webhooks: 'additional-features/webhooks.md' - Webhooks: 'additional-features/webhooks.md'
- Plugins: - Plugins:
- Using Plugins: 'plugins/index.md' - 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: - Administration:
- Authentication: 'administration/authentication.md' - Authentication: 'administration/authentication.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'

View File

@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', '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', '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.filtersets import CableTerminationFilterSet
from dcim.models import Region, Site, SiteGroup from dcim.models import Region, Site, SiteGroup
from extras.filters import TagFilter from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet
from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import TreeNodeMultipleChoiceFilter from utilities.filters import TreeNodeMultipleChoiceFilter
from .choices import * from .choices import *
@ -19,7 +18,7 @@ __all__ = (
) )
class ProviderFilterSet(PrimaryModelFilterSet): class ProviderFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -61,7 +60,6 @@ class ProviderFilterSet(PrimaryModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = Provider model = Provider
@ -79,7 +77,7 @@ class ProviderFilterSet(PrimaryModelFilterSet):
) )
class ProviderNetworkFilterSet(PrimaryModelFilterSet): class ProviderNetworkFilterSet(NetBoxModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -94,7 +92,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Provider (slug)', label='Provider (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = ProviderNetwork model = ProviderNetwork
@ -112,14 +109,13 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class CircuitTypeFilterSet(OrganizationalModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet):
tag = TagFilter()
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',
@ -190,7 +186,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
tag = TagFilter()
class Meta: class Meta:
model = Circuit 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 circuits.choices import *
from dcim.models import LinkTermination from dcim.models import LinkTermination
from extras.utils import extras_features from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models.features import WebhooksMixin
__all__ = ( __all__ = (
'Circuit', 'Circuit',
@ -15,7 +15,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class CircuitType(OrganizationalModel): class CircuitType(OrganizationalModel):
""" """
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named 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]) return reverse('circuits:circuittype', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Circuit(NetBoxModel):
class Circuit(PrimaryModel):
""" """
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple 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 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') return CircuitStatusChoices.colors.get(self.status, 'secondary')
@extras_features('webhooks') class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, 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.") raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
def to_objectchange(self, action): def to_objectchange(self, action):
# Annotate the parent Circuit objectchange = super().to_objectchange(action)
try: objectchange.related_object = self.circuit
circuit = self.circuit return objectchange
except Circuit.DoesNotExist:
# Parent circuit has been deleted
circuit = None
return super().to_objectchange(action, related_object=circuit)
@property @property
def parent_object(self): def parent_object(self):

View File

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

View File

@ -1,11 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import * from .models import *
__all__ = ( __all__ = (
'CircuitTable', 'CircuitTable',
'CircuitTypeTable', '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 # Providers
# #
class ProviderTable(BaseTable): class ProviderTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
@ -36,16 +55,16 @@ class ProviderTable(BaseTable):
accessor=Accessor('count_circuits'), accessor=Accessor('count_circuits'),
verbose_name='Circuits' verbose_name='Circuits'
) )
comments = MarkdownColumn() comments = columns.MarkdownColumn()
tags = TagColumn( tags = columns.TagColumn(
url_name='circuits:provider_list' url_name='circuits:provider_list'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', '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') default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -54,22 +73,23 @@ class ProviderTable(BaseTable):
# Provider networks # Provider networks
# #
class ProviderNetworkTable(BaseTable): class ProviderNetworkTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
provider = tables.Column( provider = tables.Column(
linkify=True linkify=True
) )
comments = MarkdownColumn() comments = columns.MarkdownColumn()
tags = TagColumn( tags = columns.TagColumn(
url_name='circuits:providernetwork_list' url_name='circuits:providernetwork_list'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = ProviderNetwork 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') default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
@ -77,31 +97,30 @@ class ProviderNetworkTable(BaseTable):
# Circuit types # Circuit types
# #
class CircuitTypeTable(BaseTable): class CircuitTypeTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
tags = TagColumn( tags = columns.TagColumn(
url_name='circuits:circuittype_list' url_name='circuits:circuittype_list'
) )
circuit_count = tables.Column( circuit_count = tables.Column(
verbose_name='Circuits' verbose_name='Circuits'
) )
actions = ButtonsColumn(CircuitType)
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = CircuitType model = CircuitType
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
# #
# Circuits # Circuits
# #
class CircuitTable(BaseTable): class CircuitTable(NetBoxTable):
pk = ToggleColumn()
cid = tables.Column( cid = tables.Column(
linkify=True, linkify=True,
verbose_name='Circuit ID' verbose_name='Circuit ID'
@ -109,7 +128,7 @@ class CircuitTable(BaseTable):
provider = tables.Column( provider = tables.Column(
linkify=True linkify=True
) )
status = ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
tenant = TenantColumn() tenant = TenantColumn()
termination_a = tables.TemplateColumn( termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
@ -119,16 +138,17 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name='Side Z'
) )
comments = MarkdownColumn() commit_rate = CommitRateColumn()
tags = TagColumn( comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = Circuit model = Circuit
fields = ( fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', '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 = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', '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 netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table from netbox.tables import configure_table
from utilities.utils import count_related from utilities.utils import count_related
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import CircuitTerminationSideChoices
from .models import * from .models import *
@ -35,7 +34,7 @@ class ProviderView(generic.ObjectView):
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,
@ -96,7 +95,7 @@ class ProviderNetworkView(generic.ObjectView):
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,
@ -150,7 +149,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',)) circuits_table = tables.CircuitTable(circuits, exclude=('type',))
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,

View File

@ -6,7 +6,9 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models 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 ipam.models import ASN, VLAN
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ( from netbox.api.serializers import (
@ -219,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ 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', 'custom_fields',
] ]
@ -719,6 +721,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
bridge = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) 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_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@ -728,6 +731,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
required=False, required=False,
many=True many=True
) )
vrf = NestedVRFSerializer(required=False, allow_null=True)
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True)
wireless_lans = SerializedPKRelatedField( wireless_lans = SerializedPKRelatedField(
@ -743,9 +747,9 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con
model = Interface model = Interface
fields = [ fields = [
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', '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', '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', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created',
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
] ]
@ -910,7 +914,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', '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', '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): def _get_termination(self, obj, side):
@ -1004,7 +1008,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = VirtualChassis 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: class Meta:
model = PowerPanel 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): class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@ -583,7 +583,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related( queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', '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 serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet filterset_class = filtersets.InterfaceFilterSet

View File

@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack' TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' 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_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'), (TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), (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_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), (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): class InterfaceModeChoices(ChoiceSet):
MODE_ACCESS = 'access' MODE_ACCESS = 'access'

View File

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

View File

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

View File

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

View File

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

View File

@ -9,12 +9,12 @@ from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelForm from extras.forms import CustomFieldModelForm
from extras.models import Tag 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 tenancy.forms import TenancyForm
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
SlugField, StaticSelect, SlugField, StaticSelect, SelectSpeedWidget,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup from wireless.models import WirelessLAN, WirelessLANGroup
@ -1261,6 +1261,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'available_on_device': '$device', 'available_on_device': '$device',
} }
) )
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
)
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -1269,13 +1274,13 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
class Meta: class Meta:
model = Interface model = Interface
fields = [ 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', '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 = ( fieldsets = (
('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), ('Interface', ('device', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
('Addressing', ('mac_address', 'wwn')), ('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')), ('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
@ -1287,6 +1292,8 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(), 'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'duplex': StaticSelect(),
'mode': StaticSelect(), 'mode': StaticSelect(),
'rf_role': StaticSelect(), 'rf_role': StaticSelect(),
'rf_channel': 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.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object 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 NetBoxModel
from netbox.models import BigIDModel, PrimaryModel
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.utils import to_meters from utilities.utils import to_meters
from .devices import Device from .devices import Device
@ -29,8 +28,7 @@ __all__ = (
# Cables # Cables
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Cable(NetBoxModel):
class Cable(PrimaryModel):
""" """
A physical connection between two endpoints. A physical connection between two endpoints.
""" """
@ -40,7 +38,7 @@ class Cable(PrimaryModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'
) )
termination_a_id = models.PositiveIntegerField() termination_a_id = models.PositiveBigIntegerField()
termination_a = GenericForeignKey( termination_a = GenericForeignKey(
ct_field='termination_a_type', ct_field='termination_a_type',
fk_field='termination_a_id' fk_field='termination_a_id'
@ -51,7 +49,7 @@ class Cable(PrimaryModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='+' related_name='+'
) )
termination_b_id = models.PositiveIntegerField() termination_b_id = models.PositiveBigIntegerField()
termination_b = GenericForeignKey( termination_b = GenericForeignKey(
ct_field='termination_b_type', ct_field='termination_b_type',
fk_field='termination_b_id' fk_field='termination_b_id'
@ -300,7 +298,7 @@ class Cable(PrimaryModel):
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] 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 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 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, on_delete=models.CASCADE,
related_name='+' related_name='+'
) )
origin_id = models.PositiveIntegerField() origin_id = models.PositiveBigIntegerField()
origin = GenericForeignKey( origin = GenericForeignKey(
ct_field='origin_type', ct_field='origin_type',
fk_field='origin_id' fk_field='origin_id'
@ -341,7 +339,7 @@ class CablePath(BigIDModel):
blank=True, blank=True,
null=True null=True
) )
destination_id = models.PositiveIntegerField( destination_id = models.PositiveBigIntegerField(
blank=True, blank=True,
null=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.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
@ -32,7 +32,7 @@ __all__ = (
) )
class ComponentTemplateModel(ChangeLoggedModel): class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
device_type = models.ForeignKey( device_type = models.ForeignKey(
to='dcim.DeviceType', to='dcim.DeviceType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -70,14 +70,10 @@ class ComponentTemplateModel(ChangeLoggedModel):
""" """
raise NotImplementedError() raise NotImplementedError()
def to_objectchange(self, action, related_object=None): def to_objectchange(self, action):
# Annotate the parent DeviceType objectchange = super().to_objectchange(action)
try: objectchange.related_object = self.device_type
device_type = self.device_type return objectchange
except ObjectDoesNotExist:
# The parent DeviceType has already been deleted
device_type = None
return super().to_objectchange(action, related_object=device_type)
class ModularComponentTemplateModel(ComponentTemplateModel): class ModularComponentTemplateModel(ComponentTemplateModel):
@ -102,19 +98,13 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
class Meta: class Meta:
abstract = True abstract = True
def to_objectchange(self, action, related_object=None): def to_objectchange(self, action):
# Annotate the parent DeviceType or ModuleType objectchange = super().to_objectchange(action)
try: if self.device_type is not None:
if getattr(self, 'device_type'): objectchange.related_object = self.device_type
return super().to_objectchange(action, related_object=self.device_type) elif self.module_type is not None:
except ObjectDoesNotExist: objectchange.related_object = self.module_type
pass return objectchange
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 clean(self): def clean(self):
super().clean() super().clean()
@ -135,7 +125,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return self.name return self.name
@extras_features('webhooks')
class ConsolePortTemplate(ModularComponentTemplateModel): class ConsolePortTemplate(ModularComponentTemplateModel):
""" """
A template for a ConsolePort to be created for a new Device. 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): class ConsoleServerPortTemplate(ModularComponentTemplateModel):
""" """
A template for a ConsoleServerPort to be created for a new Device. 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): class PowerPortTemplate(ModularComponentTemplateModel):
""" """
A template for a PowerPort to be created for a new Device. 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): class PowerOutletTemplate(ModularComponentTemplateModel):
""" """
A template for a PowerOutlet to be created for a new Device. 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): class InterfaceTemplate(ModularComponentTemplateModel):
""" """
A template for a physical data interface on a new Device. A template for a physical data interface on a new Device.
@ -347,7 +332,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class FrontPortTemplate(ModularComponentTemplateModel): class FrontPortTemplate(ModularComponentTemplateModel):
""" """
Template for a pass-through port on the front of a new Device. 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): class RearPortTemplate(ModularComponentTemplateModel):
""" """
Template for a pass-through port on the rear of a new Device. 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): class ModuleBayTemplate(ComponentTemplateModel):
""" """
A template for a ModuleBay to be created for a new parent Device. 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): class DeviceBayTemplate(ComponentTemplateModel):
""" """
A template for a DeviceBay to be created for a new parent Device. 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): class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
""" """
A template for an InventoryItem to be created for a new parent Device. 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.constants import *
from dcim.fields import MACAddressField, WWNField from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from extras.utils import extras_features from netbox.models import OrganizationalModel, NetBoxModel
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager 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. An abstract model inherited by any model which has a parent Device.
""" """
@ -76,13 +75,9 @@ class ComponentModel(PrimaryModel):
return self.name return self.name
def to_objectchange(self, action): def to_objectchange(self, action):
# Annotate the parent Device objectchange = super().to_objectchange(action)
try: objectchange.related_object = self.device
device = self.device return super().to_objectchange(action)
except ObjectDoesNotExist:
# The parent Device has already been deleted
device = None
return super().to_objectchange(action, related_object=device)
@property @property
def parent_object(self): def parent_object(self):
@ -131,7 +126,7 @@ class LinkTermination(models.Model):
blank=True, blank=True,
null=True null=True
) )
_link_peer_id = models.PositiveIntegerField( _link_peer_id = models.PositiveBigIntegerField(
blank=True, blank=True,
null=True null=True
) )
@ -254,7 +249,6 @@ class PathEndpoint(models.Model):
# Console components # Console components
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. 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}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. 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 # Power components
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. 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): class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. 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() return self.fhrp_group_assignments.count()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. 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', verbose_name='Management only',
help_text='This interface is used only for out-of-band management' 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( wwn = WWNField(
null=True, null=True,
blank=True, blank=True,
@ -616,6 +616,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
blank=True, blank=True,
verbose_name='Tagged VLANs' 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( ip_addresses = GenericRelation(
to='ipam.IPAddress', to='ipam.IPAddress',
content_type_field='assigned_object_type', content_type_field='assigned_object_type',
@ -785,7 +793,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
# Pass-through ports # Pass-through ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ModularComponentModel, LinkTermination): class FrontPort(ModularComponentModel, LinkTermination):
""" """
A pass-through port on the front of a Device. 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): class RearPort(ModularComponentModel, LinkTermination):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
@ -883,7 +889,6 @@ class RearPort(ModularComponentModel, LinkTermination):
# Bays # Bays
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ModuleBay(ComponentModel): class ModuleBay(ComponentModel):
""" """
An empty space within a Device which can house a child device 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}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel):
""" """
An empty space within a Device which can house a child device 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): class InventoryItemRole(OrganizationalModel):
""" """
Inventory items may optionally be assigned a functional role. Inventory items may optionally be assigned a functional role.
@ -986,7 +989,6 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk]) return reverse('dcim:inventoryitemrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. 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 dcim.constants import *
from extras.models import ConfigContextModel from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
from netbox.config import ConfigItem from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from .device_components import * from .device_components import *
@ -37,7 +36,6 @@ __all__ = (
# Device Types # Device Types
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel): class Manufacturer(OrganizationalModel):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. 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]) return reverse('dcim:manufacturer', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceType(NetBoxModel):
class DeviceType(PrimaryModel):
""" """
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as 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). well as high-level functional role(s).
@ -353,8 +350,7 @@ class DeviceType(PrimaryModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ModuleType(NetBoxModel):
class ModuleType(PrimaryModel):
""" """
A ModuleType represents a hardware element that can be installed within a device and which houses additional 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 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 # Devices
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel): class DeviceRole(OrganizationalModel):
""" """
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a 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]) return reverse('dcim:devicerole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Platform(OrganizationalModel): class Platform(OrganizationalModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". 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]) return reverse('dcim:platform', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Device(NetBoxModel, ConfigContextModel):
class Device(PrimaryModel, ConfigContextModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, 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. 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') return DeviceStatusChoices.colors.get(self.status, 'secondary')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Module(NetBoxModel, ConfigContextModel):
class Module(PrimaryModel, ConfigContextModel):
""" """
A Module represents a field-installable component within a Device which may itself hold multiple device components 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. (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
@ -1095,8 +1087,7 @@ class Module(PrimaryModel, ConfigContextModel):
# Virtual chassis # Virtual chassis
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VirtualChassis(NetBoxModel):
class VirtualChassis(PrimaryModel):
""" """
A collection of Devices which operate with a shared control plane (e.g. a switch stack). 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.choices import *
from dcim.constants import * from dcim.constants import *
from extras.utils import extras_features from netbox.models import NetBoxModel
from netbox.models import PrimaryModel
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint from .device_components import LinkTermination, PathEndpoint
@ -21,8 +20,7 @@ __all__ = (
# Power # Power
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPanel(NetBoxModel):
class PowerPanel(PrimaryModel):
""" """
A distribution point for electrical power; e.g. a data center RPP. 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(NetBoxModel, PathEndpoint, LinkTermination):
class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
""" """
An electrical circuit delivered from a PowerPanel. An electrical circuit delivered from a PowerPanel.
""" """

View File

@ -13,9 +13,8 @@ from django.urls import reverse
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.svg import RackElevationSVG from dcim.svg import RackElevationSVG
from extras.utils import extras_features
from netbox.config import get_config 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.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string from utilities.utils import array_to_string
@ -34,7 +33,6 @@ __all__ = (
# Racks # Racks
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel): class RackRole(OrganizationalModel):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
@ -65,8 +63,7 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk]) return reverse('dcim:rackrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Rack(NetBoxModel):
class Rack(PrimaryModel):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. 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. 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) return int(allocated_draw_total / available_power_total * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackReservation(NetBoxModel):
class RackReservation(PrimaryModel):
""" """
One or more reserved units within a Rack. 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.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import ASNField from netbox.models import NestedGroupModel, NetBoxModel
from extras.utils import extras_features
from netbox.models import NestedGroupModel, PrimaryModel
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
__all__ = ( __all__ = (
@ -24,7 +22,6 @@ __all__ = (
# Regions # Regions
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel): class Region(NestedGroupModel):
""" """
A region represents a geographic collection of sites. For example, you might create regions representing countries, 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 # Site groups
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel): class SiteGroup(NestedGroupModel):
""" """
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and 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 # Sites
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Site(NetBoxModel):
class Site(PrimaryModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility 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). 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 # Locations
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel): class Location(NestedGroupModel):
""" """
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a 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): 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: class RackElevationSVG:

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import django_tables2 as tables import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel 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 from .devices import CableTerminationTable
__all__ = ( __all__ = (
@ -14,26 +14,25 @@ __all__ = (
# Power panels # Power panels
# #
class PowerPanelTable(BaseTable): class PowerPanelTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
site = tables.Column( site = tables.Column(
linkify=True linkify=True
) )
powerfeed_count = LinkedCountColumn( powerfeed_count = columns.LinkedCountColumn(
viewname='dcim:powerfeed_list', viewname='dcim:powerfeed_list',
url_params={'power_panel_id': 'pk'}, url_params={'power_panel_id': 'pk'},
verbose_name='Feeds' verbose_name='Feeds'
) )
tags = TagColumn( tags = columns.TagColumn(
url_name='dcim:powerpanel_list' url_name='dcim:powerpanel_list'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = PowerPanel 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') 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 # We're not using PathEndpointTable for PowerFeed because power connections
# cannot traverse pass-through ports. # cannot traverse pass-through ports.
class PowerFeedTable(CableTerminationTable): class PowerFeedTable(CableTerminationTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
linkify=True linkify=True
) )
@ -54,25 +52,25 @@ class PowerFeedTable(CableTerminationTable):
rack = tables.Column( rack = tables.Column(
linkify=True linkify=True
) )
status = ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
type = ChoiceFieldColumn() type = columns.ChoiceFieldColumn()
max_utilization = tables.TemplateColumn( max_utilization = tables.TemplateColumn(
template_code="{{ value }}%" template_code="{{ value }}%"
) )
available_power = tables.Column( available_power = tables.Column(
verbose_name='Available power (VA)' verbose_name='Available power (VA)'
) )
comments = MarkdownColumn() comments = columns.MarkdownColumn()
tags = TagColumn( tags = columns.TagColumn(
url_name='dcim:powerfeed_list' url_name='dcim:powerfeed_list'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = PowerFeed model = PowerFeed
fields = ( fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', '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 django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
TagColumn, ToggleColumn, UtilizationColumn,
)
__all__ = ( __all__ = (
'RackTable', 'RackTable',
@ -19,28 +16,28 @@ __all__ = (
# Rack roles # Rack roles
# #
class RackRoleTable(BaseTable): class RackRoleTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column(linkify=True) name = tables.Column(linkify=True)
rack_count = tables.Column(verbose_name='Racks') rack_count = tables.Column(verbose_name='Racks')
color = ColorColumn() color = columns.ColorColumn()
tags = TagColumn( tags = columns.TagColumn(
url_name='dcim:rackrole_list' url_name='dcim:rackrole_list'
) )
actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = RackRole model = RackRole
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
# #
# Racks # Racks
# #
class RackTable(BaseTable): class RackTable(NetBoxTable):
pk = ToggleColumn()
name = tables.Column( name = tables.Column(
order_by=('_name',), order_by=('_name',),
linkify=True linkify=True
@ -52,27 +49,27 @@ class RackTable(BaseTable):
linkify=True linkify=True
) )
tenant = TenantColumn() tenant = TenantColumn()
status = ChoiceFieldColumn() status = columns.ChoiceFieldColumn()
role = ColoredLabelColumn() role = columns.ColoredLabelColumn()
u_height = tables.TemplateColumn( u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U", template_code="{{ record.u_height }}U",
verbose_name='Height' verbose_name='Height'
) )
comments = MarkdownColumn() comments = columns.MarkdownColumn()
device_count = LinkedCountColumn( device_count = columns.LinkedCountColumn(
viewname='dcim:device_list', viewname='dcim:device_list',
url_params={'rack_id': 'pk'}, url_params={'rack_id': 'pk'},
verbose_name='Devices' verbose_name='Devices'
) )
get_utilization = UtilizationColumn( get_utilization = columns.UtilizationColumn(
orderable=False, orderable=False,
verbose_name='Space' verbose_name='Space'
) )
get_power_utilization = UtilizationColumn( get_power_utilization = columns.UtilizationColumn(
orderable=False, orderable=False,
verbose_name='Power' verbose_name='Power'
) )
tags = TagColumn( tags = columns.TagColumn(
url_name='dcim:rack_list' url_name='dcim:rack_list'
) )
outer_width = tables.TemplateColumn( outer_width = tables.TemplateColumn(
@ -84,11 +81,12 @@ class RackTable(BaseTable):
verbose_name='Outer Depth' verbose_name='Outer Depth'
) )
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@ -100,8 +98,7 @@ class RackTable(BaseTable):
# Rack reservations # Rack reservations
# #
class RackReservationTable(BaseTable): class RackReservationTable(NetBoxTable):
pk = ToggleColumn()
reservation = tables.Column( reservation = tables.Column(
accessor='pk', accessor='pk',
linkify=True linkify=True
@ -118,17 +115,14 @@ class RackReservationTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Units' verbose_name='Units'
) )
tags = TagColumn( tags = columns.TagColumn(
url_name='dcim:rackreservation_list' url_name='dcim:rackreservation_list'
) )
actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta): class Meta(NetBoxTable.Meta):
model = RackReservation model = RackReservation
fields = ( fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
) )
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

View File

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

View File

@ -6,7 +6,7 @@ from rest_framework import status
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models 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 utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
@ -1424,6 +1424,13 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
) )
WirelessLAN.objects.bulk_create(wireless_lans) 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 = [ cls.create_data = [
{ {
'device': device.pk, 'device': device.pk,
@ -1431,9 +1438,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'type': '1000base-t', 'type': '1000base-t',
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'tx_power': 10, 'tx_power': 10,
'vrf': vrfs[0].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'speed': 1000000,
'duplex': 'full'
}, },
{ {
'device': device.pk, 'device': device.pk,
@ -1442,9 +1452,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'bridge': interfaces[0].pk, 'bridge': interfaces[0].pk,
'tx_power': 10, 'tx_power': 10,
'vrf': vrfs[1].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk],
'speed': 100000,
'duplex': 'half'
}, },
{ {
'device': device.pk, 'device': device.pk,
@ -1453,6 +1466,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
'mode': InterfaceModeChoices.MODE_TAGGED, 'mode': InterfaceModeChoices.MODE_TAGGED,
'parent': interfaces[1].pk, 'parent': interfaces[1].pk,
'tx_power': 10, 'tx_power': 10,
'vrf': vrfs[2].pk,
'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
'untagged_vlan': vlans[2].pk, 'untagged_vlan': vlans[2].pk,
'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].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.choices import *
from dcim.filtersets import * from dcim.filtersets import *
from dcim.models 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 tenancy.models import Tenant, TenantGroup
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
@ -2370,16 +2370,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Device.objects.bulk_create(devices) 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 # VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0]) 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[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) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = ( 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[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'), 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'), 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), 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 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 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), 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]} params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_mgmt_only(self):
params = {'mgmt_only': 'true'} params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
@ -2550,6 +2565,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tx_power': [40]} params = {'tx_power': [40]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) 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): class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()

View File

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

View File

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

View File

@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from rest_framework.fields import Field from rest_framework.fields import Field
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField from extras.models import CustomField
@ -44,9 +45,20 @@ class CustomFieldsDataField(Field):
return self._custom_fields return self._custom_fields
def to_representation(self, obj): def to_representation(self, obj):
return { # TODO: Fix circular import
cf.name: obj.get(cf.name) for cf in self._get_custom_fields() 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): def to_internal_value(self, data):
# If updating an existing instance, start with existing custom_field_data # If updating an existing instance, start with existing custom_field_data

View File

@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', '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', '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) type = ChoiceField(choices=CustomFieldTypeChoices)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', '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 # Custom links
@ -101,8 +115,8 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'button_class', 'new_window', 'created', 'last_updated',
] ]
@ -120,7 +134,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', '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: class Meta:
model = Tag 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_JSON = 'json'
TYPE_SELECT = 'select' TYPE_SELECT = 'select'
TYPE_MULTISELECT = 'multiselect' TYPE_MULTISELECT = 'multiselect'
TYPE_OBJECT = 'object'
TYPE_MULTIOBJECT = 'multiobject'
CHOICES = ( CHOICES = (
(TYPE_TEXT, 'Text'), (TYPE_TEXT, 'Text'),
@ -27,6 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_JSON, 'JSON'), (TYPE_JSON, 'JSON'),
(TYPE_SELECT, 'Selection'), (TYPE_SELECT, 'Selection'),
(TYPE_MULTISELECT, 'Multiple selection'), (TYPE_MULTISELECT, 'Multiple selection'),
(TYPE_OBJECT, 'Object'),
(TYPE_MULTIOBJECT, 'Multiple objects'),
) )

View File

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

View File

@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomLink 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

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

View File

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

View File

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

View File

@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -35,12 +35,16 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
model = CustomField model = CustomField
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')),
('Assigned Models', ('content_types',)), ('Assigned Models', ('content_types',)),
('Behavior', ('filter_logic',)), ('Behavior', ('filter_logic',)),
('Values', ('default', 'choices')), ('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@ -53,10 +57,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
model = CustomLink model = CustomLink
fields = '__all__' fields = '__all__'
fieldsets = ( 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')), ('Templates', ('link_text', 'link_url')),
) )
widgets = { widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': 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 model = ExportTemplate
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Custom Link', ('name', 'content_type', 'description')), ('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)), ('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
) )
@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook model = Webhook
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Webhook', ('name', 'enabled')), ('Webhook', ('name', 'content_types', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')), ('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', ( ('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)), ('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')), ('SSL', ('ssl_verification', 'ca_file_path')),
) )
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = { widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': 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 django.urls import reverse
from extras.choices import * from extras.choices import *
from netbox.models import BigIDModel
from utilities.querysets import RestrictedQuerySet 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 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 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, on_delete=models.PROTECT,
related_name='+' related_name='+'
) )
changed_object_id = models.PositiveIntegerField() changed_object_id = models.PositiveBigIntegerField()
changed_object = GenericForeignKey( changed_object = GenericForeignKey(
ct_field='changed_object_type', ct_field='changed_object_type',
fk_field='changed_object_id' fk_field='changed_object_id'
@ -55,7 +54,7 @@ class ObjectChange(BigIDModel):
blank=True, blank=True,
null=True null=True
) )
related_object_id = models.PositiveIntegerField( related_object_id = models.PositiveBigIntegerField(
blank=True, blank=True,
null=True null=True
) )

View File

@ -5,8 +5,8 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.utils import deepmerge from utilities.utils import deepmerge
@ -20,8 +20,7 @@ __all__ = (
# Config contexts # Config contexts
# #
@extras_features('webhooks') class ConfigContext(WebhooksMixin, ChangeLoggedModel):
class ConfigContext(ChangeLoggedModel):
""" """
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned 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 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 django.utils.safestring import mark_safe
from extras.choices import * from extras.choices import *
from extras.utils import FeatureQuery, extras_features from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities import filters from utilities import filters
from utilities.forms import ( 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.querysets import RestrictedQuerySet
from utilities.validators import validate_regex 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) return self.get_queryset().filter(content_types=content_type)
@extras_features('webhooks', 'export_templates') class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
related_name='custom_fields', related_name='custom_fields',
@ -50,7 +51,15 @@ class CustomField(ChangeLoggedModel):
type = models.CharField( type = models.CharField(
max_length=50, max_length=50,
choices=CustomFieldTypeChoices, 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( name = models.CharField(
max_length=50, max_length=50,
@ -122,7 +131,6 @@ class CustomField(ChangeLoggedModel):
null=True, null=True,
help_text='Comma-separated list of available choices (for selection fields)' help_text='Comma-separated list of available choices (for selection fields)'
) )
objects = CustomFieldManager() objects = CustomFieldManager()
class Meta: class Meta:
@ -234,11 +242,48 @@ class CustomField(ChangeLoggedModel):
'default': f"The specified default value ({self.default}) is not listed as an available choice." '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): 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. 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. 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. 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() choices=choices, required=required, initial=initial, widget=StaticSelect()
) )
else: else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class( field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
) )
@ -300,6 +345,24 @@ class CustomField(ChangeLoggedModel):
elif self.type == CustomFieldTypeChoices.TYPE_JSON: elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = forms.JSONField(required=required, initial=initial) 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 # Text
else: else:
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: 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.choices import *
from extras.constants import * from extras.constants import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.utils import extras_features, FeatureQuery, image_upload from extras.utils import FeatureQuery, image_upload
from netbox.models import BigIDModel, ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2 from utilities.utils import render_jinja2
@ -35,8 +36,7 @@ __all__ = (
) )
@extras_features('webhooks', 'export_templates') class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class Webhook(ChangeLoggedModel):
""" """
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or 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. 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( payload_url = models.CharField(
max_length=500, max_length=500,
verbose_name='URL', 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( enabled = models.BooleanField(
default=True default=True
@ -176,9 +177,14 @@ class Webhook(ChangeLoggedModel):
else: else:
return json.dumps(context, cls=JSONEncoder) 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 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. code to be rendered with an object as context.
@ -192,6 +198,9 @@ class CustomLink(ChangeLoggedModel):
max_length=100, max_length=100,
unique=True unique=True
) )
enabled = models.BooleanField(
default=True
)
link_text = models.CharField( link_text = models.CharField(
max_length=500, max_length=500,
help_text="Jinja2 template code for link text" help_text="Jinja2 template code for link text"
@ -248,8 +257,7 @@ class CustomLink(ChangeLoggedModel):
} }
@extras_features('webhooks', 'export_templates') class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -335,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel):
return response return response
@extras_features('webhooks') class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
class ImageAttachment(ChangeLoggedModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
""" """
@ -344,7 +351,7 @@ class ImageAttachment(ChangeLoggedModel):
to=ContentType, to=ContentType,
on_delete=models.CASCADE on_delete=models.CASCADE
) )
object_id = models.PositiveIntegerField() object_id = models.PositiveBigIntegerField()
parent = GenericForeignKey( parent = GenericForeignKey(
ct_field='content_type', ct_field='content_type',
fk_field='object_id' fk_field='object_id'
@ -411,11 +418,12 @@ class ImageAttachment(ChangeLoggedModel):
return None return None
def to_objectchange(self, action): 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(WebhooksMixin, ChangeLoggedModel):
class JournalEntry(ChangeLoggedModel):
""" """
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to 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 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, to=ContentType,
on_delete=models.CASCADE on_delete=models.CASCADE
) )
assigned_object_id = models.PositiveIntegerField() assigned_object_id = models.PositiveBigIntegerField()
assigned_object = GenericForeignKey( assigned_object = GenericForeignKey(
ct_field='assigned_object_type', ct_field='assigned_object_type',
fk_field='assigned_object_id' fk_field='assigned_object_id'
@ -461,7 +469,7 @@ class JournalEntry(ChangeLoggedModel):
return JournalEntryKindChoices.colors.get(self.kind) return JournalEntryKindChoices.colors.get(self.kind)
class JobResult(BigIDModel): class JobResult(models.Model):
""" """
This model stores the results from running a user-defined report. This model stores the results from running a user-defined report.
""" """
@ -593,8 +601,7 @@ class ConfigRevision(models.Model):
# Custom scripts & reports # Custom scripts & reports
# #
@extras_features('job_results') class Script(JobResultsMixin, models.Model):
class Script(models.Model):
""" """
Dummy model used to generate permissions for custom scripts. Does not exist in the database. Dummy model used to generate permissions for custom scripts. Does not exist in the database.
""" """
@ -606,8 +613,7 @@ class Script(models.Model):
# Reports # Reports
# #
@extras_features('job_results') class Report(JobResultsMixin, models.Model):
class Report(models.Model):
""" """
Dummy model used to generate permissions for reports. Does not exist in the database. 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 django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import extras_features from netbox.models import ChangeLoggedModel
from netbox.models import BigIDModel, ChangeLoggedModel from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField from utilities.fields import ColorField
@ -13,8 +13,10 @@ from utilities.fields import ColorField
# Tags # Tags
# #
@extras_features('webhooks', 'export_templates') class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
class Tag(ChangeLoggedModel, TagBase): id = models.BigAutoField(
primary_key=True
)
color = ColorField( color = ColorField(
default=ColorChoices.COLOR_GREY default=ColorChoices.COLOR_GREY
) )
@ -37,7 +39,7 @@ class Tag(ChangeLoggedModel, TagBase):
return slug return slug
class TaggedItem(BigIDModel, GenericTaggedItemBase): class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey( tag = models.ForeignKey(
to=Tag, to=Tag,
related_name="%(app_label)s_%(class)s_items", 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): class Registry(dict):
""" """
Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or 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: try:
return super().__getitem__(key) return super().__getitem__(key)
except KeyError: except KeyError:
raise KeyError("Invalid store: {}".format(key)) raise KeyError(f"Invalid store: {key}")
def __setitem__(self, key, value): def __setitem__(self, key, value):
if key in self: if key in self:
raise KeyError("Store already set: {}".format(key)) raise KeyError(f"Store already set: {key}")
super().__setitem__(key, value) super().__setitem__(key, value)
def __delitem__(self, key): def __delitem__(self, key):
raise TypeError("Cannot delete stores from registry") raise TypeError("Cannot delete stores from registry")
# Initialize the global registry
registry = 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.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction 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 .context_managers import change_logging
from .forms import ScriptForm from .forms import ScriptForm
@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs): def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Set field choices # Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = choices self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar): class MultiChoiceVar(ScriptVariable):
""" """
Like ChoiceVar, but allows for the selection of multiple choices. Like ChoiceVar, but allows for the selection of multiple choices.
""" """
form_field = forms.MultipleChoiceField 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): class ObjectVar(ScriptVariable):
""" """

View File

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

View File

@ -36,7 +36,7 @@ def custom_links(context, obj):
Render all applicable links for the given object. Render all applicable links for the given object.
""" """
content_type = ContentType.objects.get_for_model(obj) 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: if not custom_links:
return '' return ''

View File

@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 4', 'name': 'Custom Link 4',
'enabled': True,
'link_text': 'Link 4', 'link_text': 'Link 4',
'link_url': 'http://example.com/?4', 'link_url': 'http://example.com/?4',
}, },
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 5', 'name': 'Custom Link 5',
'enabled': True,
'link_text': 'Link 5', 'link_text': 'Link 5',
'link_url': 'http://example.com/?5', 'link_url': 'http://example.com/?5',
}, },
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 6', 'name': 'Custom Link 6',
'enabled': False,
'link_text': 'Link 6', 'link_text': 'Link 6',
'link_url': 'http://example.com/?6', 'link_url': 'http://example.com/?6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
'new_window': True, 'new_window': True,
'enabled': False,
} }
@classmethod @classmethod
@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 1', name='Custom Link 1',
enabled=True,
link_text='Link 1', link_text='Link 1',
link_url='http://example.com/?1', link_url='http://example.com/?1',
), ),
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 2', name='Custom Link 2',
enabled=True,
link_text='Link 2', link_text='Link 2',
link_url='http://example.com/?2', link_url='http://example.com/?2',
), ),
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 3', name='Custom Link 3',
enabled=False,
link_text='Link 3', link_text='Link 3',
link_url='http://example.com/?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 dcim.models import Site, Rack
from extras.choices import * from extras.choices import *
from extras.models import CustomField from extras.models import CustomField
from ipam.models import VLAN
from utilities.testing import APITestCase, TestCase from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
class CustomFieldTest(TestCase): class CustomFieldTest(TestCase):
def setUp(self): @classmethod
def setUpTestData(cls):
Site.objects.bulk_create([ Site.objects.bulk_create([
Site(name='Site A', slug='site-a'), Site(name='Site A', slug='site-a'),
@ -22,137 +24,294 @@ class CustomFieldTest(TestCase):
Site(name='Site C', slug='site-c'), Site(name='Site C', slug='site-c'),
]) ])
def test_simple_fields(self): cls.object_type = ContentType.objects.get_for_model(Site)
DATA = (
{ def test_text_field(self):
'field': { value = 'Foobar!'
'type': CustomFieldTypeChoices.TYPE_TEXT,
}, # Create a custom field & check that initial value is null
'value': 'Foobar!', cf = CustomField.objects.create(
}, name='text_field',
{ type=CustomFieldTypeChoices.TYPE_TEXT,
'field': { required=False
'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}',
},
) )
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 def test_longtext_field(self):
cf = CustomField(name='my_field', required=False, **data['field']) value = 'A' * 256
cf.save()
cf.content_types.set([obj_type])
# Check that the field has a null initial value # Create a custom field & check that initial value is null
site = Site.objects.first() cf = CustomField.objects.create(
self.assertIsNone(site.custom_field_data[cf.name]) 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 # Assign a value and check that it is saved
site.custom_field_data[cf.name] = data['value'] instance.custom_field_data[cf.name] = value
site.save() instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Retrieve the stored value # Delete the stored value and check that it is now null
site.refresh_from_db() instance.custom_field_data.pop(cf.name)
self.assertEqual(site.custom_field_data[cf.name], data['value']) instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Delete the stored value def test_integer_field(self):
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
# Delete the custom field # Create a custom field & check that initial value is null
cf.delete() 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): 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 # Create a custom field & check that initial value is null
cf = CustomField( cf = CustomField.objects.create(
name='select_field',
type=CustomFieldTypeChoices.TYPE_SELECT, type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field',
required=False, required=False,
choices=['Option A', 'Option B', 'Option C'] choices=CHOICES
) )
cf.save() cf.content_types.set([self.object_type])
cf.content_types.set([obj_type]) instance = Site.objects.first()
self.assertIsNone(instance.custom_field_data[cf.name])
# Check that the field has a null initial value # Assign a value and check that it is saved
site = Site.objects.first() instance.custom_field_data[cf.name] = value
self.assertIsNone(site.custom_field_data[cf.name]) instance.save()
instance.refresh_from_db()
self.assertEqual(instance.custom_field_data[cf.name], value)
# Assign a value to the first Site # Delete the stored value and check that it is now null
site.custom_field_data[cf.name] = 'Option A' instance.custom_field_data.pop(cf.name)
site.save() instance.save()
instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name))
# Retrieve the stored value def test_multiselect_field(self):
site.refresh_from_db() CHOICES = ['Option A', 'Option B', 'Option C']
self.assertEqual(site.custom_field_data[cf.name], 'Option A') value = [CHOICES[1], CHOICES[2]]
# Delete the stored value # Create a custom field & check that initial value is null
site.custom_field_data.pop(cf.name) cf = CustomField.objects.create(
site.save() name='multiselect_field',
site.refresh_from_db() type=CustomFieldTypeChoices.TYPE_MULTISELECT,
self.assertIsNone(site.custom_field_data.get(cf.name)) 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 # Assign a value and check that it is saved
cf.delete() 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): def test_rename_customfield(self):
obj_type = ContentType.objects.get_for_model(Site) obj_type = ContentType.objects.get_for_model(Site)
@ -201,76 +360,116 @@ class CustomFieldAPITest(APITestCase):
def setUpTestData(cls): def setUpTestData(cls):
content_type = ContentType.objects.get_for_model(Site) content_type = ContentType.objects.get_for_model(Site)
# Text custom field # Create some VLANs
cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') vlans = (
cls.cf_text.save() VLAN(name='VLAN 1', vid=1),
cls.cf_text.content_types.set([content_type]) 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 custom_fields = (
cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
cls.cf_longtext.save() CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
cls.cf_longtext.content_types.set([content_type]) 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 # Create some sites *after* creating the custom fields. This ensures that
cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) # default values are not set for the assigned objects.
cls.cf_integer.save() sites = (
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 = (
Site(name='Site 1', slug='site-1'), Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'), 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 # Assign custom field values for site 2
cls.sites[1].custom_field_data = { sites[1].custom_field_data = {
cls.cf_text.name: 'bar', custom_fields[0].name: 'bar',
cls.cf_longtext.name: 'DEF', custom_fields[1].name: 'DEF',
cls.cf_integer.name: 456, custom_fields[2].name: 456,
cls.cf_boolean.name: True, custom_fields[3].name: True,
cls.cf_date.name: '2020-01-02', custom_fields[4].name: '2020-01-02',
cls.cf_url.name: 'http://example.com/2', custom_fields[5].name: 'http://example.com/2',
cls.cf_json.name: '{"foo": 1, "bar": 2}', custom_fields[6].name: '{"foo": 1, "bar": 2}',
cls.cf_select.name: 'Bar', 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): 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. 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') self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header) 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'], { self.assertEqual(response.data['custom_fields'], {
'text_field': None, 'text_field': None,
'longtext_field': None, 'longtext_field': None,
@ -279,19 +478,23 @@ class CustomFieldAPITest(APITestCase):
'date_field': None, 'date_field': None,
'url_field': None, 'url_field': None,
'json_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): 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. Validate that custom fields are present and correctly set for an object with values defined.
""" """
site2_cfvs = self.sites[1].custom_field_data site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) site2_cfvs = site2.custom_field_data
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.view_site') self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header) 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']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_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']) 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']['date_field'], site2_cfvs['date_field'])
self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_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']['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): 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. 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 = { data = {
'name': 'Site 3', 'name': 'Site 3',
'slug': 'site-3', 'slug': 'site-3',
@ -317,25 +529,34 @@ class CustomFieldAPITest(APITestCase):
# Validate response data # Validate response data
response_cf = response.data['custom_fields'] response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], self.cf_select.default) 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 # Validate database data
site = Site.objects.get(pk=response.data['id']) 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['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) 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): def test_create_single_object_with_values(self):
""" """
@ -352,7 +573,10 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 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') 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['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_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['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 # Validate database data
site = Site.objects.get(pk=response.data['id']) 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(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['url_field'], data_cf['url_field'])
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_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): 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. the default custom field values.
""" """
cf_defaults = {
cf.name: cf.default for cf in CustomField.objects.all()
}
data = ( data = (
{ {
'name': 'Site 3', 'name': 'Site 3',
@ -414,25 +650,34 @@ class CustomFieldAPITest(APITestCase):
# Validate response data # Validate response data
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], self.cf_text.default) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], self.cf_integer.default) self.assertEqual(response_cf['number_field'], cf_defaults['number_field'])
self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], self.cf_date.default) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], self.cf_url.default) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], self.cf_json.default) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], self.cf_select.default) 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 # Validate database data
site = Site.objects.get(pk=response.data[i]['id']) 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['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field'])
self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) 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): def test_create_multiple_objects_with_values(self):
""" """
@ -446,7 +691,10 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 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 = ( data = (
{ {
@ -483,7 +731,13 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) 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['url_field'], custom_field_data['url_field'])
self.assertEqual(response_cf['json_field'], custom_field_data['json_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 # Validate database data
site = Site.objects.get(pk=response.data[i]['id']) 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(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['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['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): 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 Update an object with existing custom field values. Ensure that only the updated custom field values are
modified. modified.
""" """
site = self.sites[1] site2 = Site.objects.get(name='Site 2')
original_cfvs = {**site.custom_field_data} original_cfvs = {**site2.custom_field_data}
data = { data = {
'custom_fields': { 'custom_fields': {
'text_field': 'ABCD', 'text_field': 'ABCD',
'number_field': 1234, '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') self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header) 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['date_field'], original_cfvs['date_field'])
self.assertEqual(response_cf['url_field'], original_cfvs['url_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['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 # Validate database data
site.refresh_from_db() site2.refresh_from_db()
self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) self.assertEqual(site2.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(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_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): 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.add_permissions('dcim.change_site')
self.cf_integer.validation_minimum = 10 cf_integer = CustomField.objects.get(name='number_field')
self.cf_integer.validation_maximum = 20 cf_integer.validation_minimum = 10
self.cf_integer.save() cf_integer.validation_maximum = 20
cf_integer.save()
data = {'custom_fields': {'number_field': 9}} data = {'custom_fields': {'number_field': 9}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
@ -558,11 +826,13 @@ class CustomFieldAPITest(APITestCase):
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
def test_regex_validation(self): 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.add_permissions('dcim.change_site')
self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters cf_text = CustomField.objects.get(name='text_field')
self.cf_text.save() cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters
cf_text.save()
data = {'custom_fields': {'text_field': 'ABC123'}} data = {'custom_fields': {'text_field': 'ABC123'}}
response = self.client.patch(url, data, format='json', **self.header) 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=[ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C', '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: for cf in custom_fields:
cf.save() cf.save()
@ -607,19 +880,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField. Import a Site in CSV format, including a value for each CustomField.
""" """
data = ( data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), ('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'), ('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'), ('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', '', '', '', '', '', '', '', ''), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
) )
csv_data = '\n'.join(','.join(row) for row in data) csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1 # Validate data for site 1
site1 = Site.objects.get(name='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['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123) 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['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A') 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 # Validate data for site 2
site2 = Site.objects.get(name='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['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456) 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['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B') 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 # No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3') site3 = Site.objects.get(name='Site 3')

View File

@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 1', name='Custom Link 1',
content_type=content_types[0], content_type=content_types[0],
enabled=True,
weight=100, weight=100,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 2', name='Custom Link 2',
content_type=content_types[1], content_type=content_types[1],
enabled=True,
weight=200, weight=200,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 3', name='Custom Link 3',
content_type=content_types[2], content_type=content_types[2],
enabled=False,
weight=300, weight=300,
new_window=True, new_window=True,
link_text='Link 1', link_text='Link 1',
@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
params = {'weight': [100, 200]} params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_new_window(self):
params = {'new_window': False} params = {'new_window': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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 = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
cf_select.content_types.set([obj_type]) cf_select.content_types.set([obj_type])
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, cf_multiselect = CustomField.objects.create(
choices=CHOICES) name='multiselect',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=CHOICES
)
cf_multiselect.content_types.set([obj_type]) 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): def test_empty_values(self):
""" """
Test that empty custom field values are stored as null 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) site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create(( 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 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, link_text='Link 2', link_url='http://example.com/?2'), 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, link_text='Link 3', link_url='http://example.com/?3'), CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
)) ))
cls.form_data = { cls.form_data = {
'name': 'Custom Link X', 'name': 'Custom Link X',
'content_type': site_ct.pk, 'content_type': site_ct.pk,
'enabled': False,
'weight': 100, 'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT, 'button_class': CustomLinkButtonClassChoices.DEFAULT,
'link_text': 'Link X', 'link_text': 'Link X',
@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,weight,button_class,link_text,link_url", "name,content_type,enabled,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CYAN, 'button_class': CustomLinkButtonClassChoices.CYAN,
'enabled': False,
'weight': 200, 'weight': 200,
} }

View File

@ -1,5 +1,3 @@
import collections
from django.db.models import Q from django.db.models import Q
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
@ -57,21 +55,9 @@ class FeatureQuery:
return query return query
def extras_features(*features): def register_features(model, 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: for feature in features:
if feature in EXTRAS_FEATURES: if feature not in EXTRAS_FEATURES:
app_label, model_name = model_class._meta.label_lower.split('.') raise ValueError(f"{feature} is not a valid extras feature!")
registry['model_features'][feature][app_label].append(model_name) app_label, model_name = model._meta.label_lower.split('.')
else: registry['model_features'][feature][app_label].add(model_name)
raise ValueError('{} is not a valid extras feature!'.format(feature))
return model_class
return wrapper

View File

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

View File

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

View File

@ -15,6 +15,7 @@ __all__ = [
'NestedRoleSerializer', 'NestedRoleSerializer',
'NestedRouteTargetSerializer', 'NestedRouteTargetSerializer',
'NestedServiceSerializer', 'NestedServiceSerializer',
'NestedServiceTemplateSerializer',
'NestedVLANGroupSerializer', 'NestedVLANGroupSerializer',
'NestedVLANSerializer', 'NestedVLANSerializer',
'NestedVRFSerializer', 'NestedVRFSerializer',
@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
# Services # 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): class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')

View File

@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # 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): class ServiceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)

View File

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

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