Merge branch 'feature' into 8366-job-scheduling

Sync with upstream
This commit is contained in:
kkthxbye-code 2022-10-09 11:18:47 +02:00
commit 53c8a48244
194 changed files with 3773 additions and 2447 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.3.4 placeholder: v3.3.5
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.3.4 placeholder: v3.3.5
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -1,5 +1,7 @@
name: CI name: CI
on: [push, pull_request] on: [push, pull_request]
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -4,6 +4,11 @@ name: 'Lock threads'
on: on:
schedule: schedule:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs: jobs:
lock: lock:
@ -11,7 +16,6 @@ jobs:
steps: steps:
- uses: dessant/lock-threads@v3 - uses: dessant/lock-threads@v3
with: with:
github-token: ${{ github.token }}
issue-inactive-days: 90 issue-inactive-days: 90
pr-inactive-days: 30 pr-inactive-days: 30
issue-lock-reason: 'resolved' issue-lock-reason: 'resolved'

View File

@ -1,14 +1,21 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs' name: 'Close stale issues/PRs'
on: on:
schedule: schedule:
- cron: '0 4 * * *' - cron: '0 4 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs: jobs:
stale: stale:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/stale@v5 - uses: actions/stale@v6
with: with:
close-issue-message: > close-issue-message: >
This issue has been automatically closed due to lack of activity. In an This issue has been automatically closed due to lack of activity. In an

View File

@ -4,7 +4,7 @@ bleach
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://github.com/django/django # https://github.com/django/django
Django<4.1 Django<4.2
# Django middleware which permits cross-domain API requests # Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers # https://github.com/OttoYiu/django-cors-headers
@ -68,7 +68,7 @@ drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support) # Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django # https://github.com/graphql-python/graphene-django
graphene_django graphene_django<3.0
# WSGI HTTP server # WSGI HTTP server
# https://gunicorn.org/ # https://gunicorn.org/
@ -80,7 +80,8 @@ Jinja2
# Simple markup language for rendering HTML # Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown # https://github.com/Python-Markdown/markdown
Markdown # mkdocs currently requires Markdown v3.3
Markdown<3.4
# File inclusion plugin for Python-Markdown # File inclusion plugin for Python-Markdown
# https://github.com/cmacmackin/markdown-include # https://github.com/cmacmackin/markdown-include

View File

@ -2,8 +2,8 @@
{% block site_meta %} {% block site_meta %}
{{ super() }} {{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #} {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
{% if not config.extra.readthedocs %} {% if page.canonical_url != 'https://docs.netbox.dev/' %}
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE ## DATABASE
NetBox requires access to a PostgreSQL 10 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name * `NAME` - Database name
* `USER` - PostgreSQL username * `USER` - PostgreSQL username

View File

@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
Default: None Default: None
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
```python ```python
HTTP_PROXIES = { HTTP_PROXIES = {

View File

@ -13,6 +13,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
* Text: Free-form text (intended for single-line use) * Text: Free-form text (intended for single-line use)
* Long text: Free-form of any length; supports Markdown rendering * Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative) * Integer: A whole number (positive or negative)
* Decimal: A fixed-precision decimal number (4 decimal places)
* Boolean: True or false * Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD) * Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI * URL: This will be presented as a link in the web UI

View File

@ -129,6 +129,19 @@ The Script object provides a set of convenient functions for recording messages
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
## Change Logging
To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.
```python
if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj.full_clean()
obj.save()
```
## Variable Reference ## Variable Reference
### Default Options ### Default Options

View File

@ -60,7 +60,7 @@ Create the HTML template for the object view. (The other views each typically em
## 10. Add the model to the navigation menu ## 10. Add the model to the navigation menu
Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`. Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
## 11. REST API components ## 11. REST API components

View File

@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning "PostgreSQL 10 or later required" !!! warning "PostgreSQL 11 or later required"
NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported. NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation ## Installation
@ -35,7 +35,7 @@ sudo systemctl start postgresql
sudo systemctl enable postgresql sudo systemctl enable postgresql
``` ```
Before continuing, verify that you have installed PostgreSQL 10 or later: Before continuing, verify that you have installed PostgreSQL 11 or later:
```no-highlight ```no-highlight
psql -V psql -V

View File

@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 10 | | PostgreSQL | 11 |
| Redis | 4.0 | | Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference: Below is a simplified overview of the NetBox application stack for reference:

View File

@ -20,7 +20,7 @@ NetBox v3.0 and later require the following:
| Dependency | Minimum Version | | Dependency | Minimum Version |
|------------|-----------------| |------------|-----------------|
| Python | 3.8 | | Python | 3.8 |
| PostgreSQL | 10 | | PostgreSQL | 11 |
| Redis | 4.0 | | Redis | 4.0 |
## 3. Install the Latest Release ## 3. Install the Latest Release
@ -28,16 +28,15 @@ NetBox v3.0 and later require the following:
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
!!! warning !!! warning
Use the same method as you used to install Netbox originally Use the same method as you used to install NetBox originally
If you are not sure how Netbox was installed originally, check with this If you are not sure how NetBox was installed originally, check with this command:
command:
``` ```
ls -ld /opt/netbox /opt/netbox/.git ls -ld /opt/netbox /opt/netbox/.git
``` ```
If Netbox was installed from a release package, then `/opt/netbox` will be a If NetBox was installed from a release package, then `/opt/netbox` will be a
symlink pointing to the current version, and `/opt/netbox/.git` will not symlink pointing to the current version, and `/opt/netbox/.git` will not
exist. If it was installed from git, then `/opt/netbox` and exist. If it was installed from git, then `/opt/netbox` and
`/opt/netbox/.git` will both exist as normal directories. `/opt/netbox/.git` will both exist as normal directories.

View File

@ -74,6 +74,6 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache | | HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI | | WSGI service | gunicorn or uWSGI |
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 10+ | | Database | PostgreSQL 11+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM (optional) | | Live device access | NAPALM (optional) |

View File

@ -41,6 +41,10 @@ Indicates whether this is a parent type (capable of housing child devices), a ch
The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules).
### Weight
The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Front & Rear Images ### Front & Rear Images
Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams.

View File

@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu
### Part Number ### Part Number
An alternative part number to uniquely identify the module type. An alternative part number to uniquely identify the module type.
### Weight
The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound).

View File

@ -65,6 +65,10 @@ The height of the rack, measured in units.
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Descending Units ### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)

View File

@ -19,6 +19,10 @@ The wire protocol employed by cooperating servers to maintain the virtual [IP ad
The group's numeric identifier. The group's numeric identifier.
### Name
An optional name for the FHRP group.
### Authentication Type ### Authentication Type
The type of authentication employed by group nodes, if any. The type of authentication employed by group nodes, if any.

View File

@ -14,6 +14,7 @@ Plugins can do a lot, including:
* Provide their own "pages" (views) in the web user interface * Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links * Inject template content and navigation links
* Extend NetBox's REST and GraphQL APIs * Extend NetBox's REST and GraphQL APIs
* Load additional Django apps
* Add custom request/response middleware * 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. 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.
@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
default_settings = { default_settings = {
'baz': True 'baz': True
} }
django_apps = ["foo", "bar", "baz"]
config = FooBarConfig config = FooBarConfig
``` ```
@ -101,6 +103,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | | `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 | | `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 | | `default_settings` | A dictionary of configuration parameters and their default values |
| `django_apps` | A list of additional Django apps to load alongside the plugin |
| `min_version` | Minimum version of NetBox with which the plugin is compatible | | `min_version` | Minimum version of NetBox with which the plugin is compatible |
| `max_version` | Maximum 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 | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware |
@ -112,6 +115,22 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
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. 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.
!!! tip "Accessing Config Parameters"
Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example:
```python
from django.conf import settings
settings.PLUGINS_CONFIG['myplugin']['verbose_name']
```
#### Important Notes About `django_apps`
Loading additional apps may cause more harm than good and could make identifying problems within NetBox itself more difficult. The `django_apps` attribute is intended only for advanced use cases that require a deeper Django integration.
Apps from this list are inserted *before* the plugin's `PluginConfig` in the order defined. Adding the plugin's `PluginConfig` module to this list changes this behavior and allows for apps to be loaded *after* the plugin.
Any additional apps must be installed within the same Python environment as NetBox or `ImproperlyConfigured` exceptions will be raised when loading the plugin.
## Create setup.py ## Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and 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 control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and 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 control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:

View File

@ -49,24 +49,6 @@ class MyModel(NetBoxModel):
... ...
``` ```
### The `clone()` Method
!!! info
This method was introduced in NetBox v3.3.
The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined.
Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content:
```python
class MyModel(NetBoxModel):
def clone(self):
attrs = super().clone()
attrs['extra-value'] = 123
return attrs
```
### Enabling Features Individually ### 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. (Your model will also need to inherit from Django's built-in `Model` class.) 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. (Your model will also need to inherit from Django's built-in `Model` class.)
@ -116,6 +98,8 @@ For more information about database migrations, see the [Django documentation](h
::: netbox.models.features.ChangeLoggingMixin ::: netbox.models.features.ChangeLoggingMixin
::: netbox.models.features.CloningMixin
::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomLinksMixin
::: netbox.models.features.CustomFieldsMixin ::: netbox.models.features.CustomFieldsMixin

View File

@ -1,25 +1,67 @@
# Navigation # Navigation
## Menus
!!! note
This feature was introduced in NetBox v3.4.
A plugin can register its own submenu as part of NetBox's navigation menu. This is done by defining a variable named `menu` in `navigation.py`, pointing to an instance of the `PluginMenu` class. Each menu must define a label and grouped menu items (discussed below), and may optionally specify an icon. An example is shown below.
```python title="navigation.py"
from extras.plugins import PluginMenu
menu = PluginMenu(
label='My Plugin',
groups=(
('Foo', (item1, item2, item3)),
('Bar', (item4, item5)),
),
icon='mdi mdi-router'
)
```
Note that each group is a two-tuple containing a label and an iterable of menu items. The group's label serves as the section header within the submenu. A group label is required even if you have only one group of items.
!!! tip
The path to the menu class can be modified by setting `menu` in the PluginConfig instance.
A `PluginMenu` has the following attributes:
| Attribute | Required | Description |
|--------------|----------|---------------------------------------------------|
| `label` | Yes | The text displayed as the menu heading |
| `groups` | Yes | An iterable of named groups containing menu items |
| `icon_class` | - | The CSS name of the icon to use for the heading |
!!! tip
Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)
### The Default Menu
If your plugin has only a small number of menu items, it may be desirable to use NetBox's shared "Plugins" menu rather than creating your own. To do this, simply declare `menu_items` as a list of `PluginMenuItems` in `navigation.py`. The listed items will appear under a heading bearing the name of your plugin in the "Plugins" submenu.
```python title="navigation.py"
menu_items = (item1, item2, item3)
```
!!! tip
The path to the menu items list can be modified by setting `menu_items` in the PluginConfig instance.
## Menu Items ## 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. Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
!!! tip ```python filename="navigation.py"
The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
```python
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
menu_items = ( item1 = PluginMenuItem(
PluginMenuItem( link='plugins:myplugin:myview',
link='plugins:netbox_animal_sounds:random_animal', link_text='Some text',
link_text='Random sound', buttons=(
buttons=( PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), )
)
),
) )
``` ```
@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes:
## Menu Buttons ## Menu Buttons
Each menu item can include a set of buttons. These can be handy for providing shortcuts related to the menu item. For instance, most items in NetBox's navigation menu include buttons to create and import new objects.
A `PluginMenuButton` has the following attributes: A `PluginMenuButton` has the following attributes:
| Attribute | Required | Description | | Attribute | Required | Description |
|---------------|----------|--------------------------------------------------------------------| |---------------|----------|--------------------------------------------------------------------|
| `link` | Yes | Name of the URL path to which this button links | | `link` | Yes | Name of the URL path to which this button links |
| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | | `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) |
| `icon_class` | Yes | Button icon CSS class* | | `icon_class` | Yes | Button icon CSS class |
| `color` | - | One of the choices provided by `ButtonColorChoices` | | `color` | - | One of the choices provided by `ButtonColorChoices` |
| `permissions` | - | A list of permissions required to display this button | | `permissions` | - | A list of permissions required to display this button |
*NetBox supports [Material Design Icons](https://materialdesignicons.com/). 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.
!!! note !!! tip
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. Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)

View File

@ -148,6 +148,32 @@ These views are provided to enable or enhance certain NetBox model features, suc
## Extending Core Views ## Extending Core Views
### Additional Tabs
Plugins can "attach" a custom view to a core NetBox model by registering it with `register_model_view()`. To include a tab for this view within the NetBox UI, declare a TabView instance named `tab`:
```python
from dcim.models import Site
from myplugin.models import Stuff
from netbox.views import generic
from utilities.views import ViewTab, register_model_view
@register_model_view(Site, 'mview', path='some-other-stuff')
class MyView(generic.ObjectView):
...
tab = ViewTab(
label='Other Stuff',
badge=lambda obj: Stuff.objects.filter(site=obj).count(),
permission='myplugin.view_stuff'
)
```
::: utilities.views.register_model_view
::: utilities.views.ViewTab
### Extra Template Content
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: 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 * `left_page()` - Inject content on the left side of the page

View File

@ -1,6 +1,34 @@
# NetBox v3.3 # NetBox v3.3
## v3.3.5 (FUTURE) ## v3.3.6 (FUTURE)
---
## v3.3.5 (2022-10-05)
### Enhancements
* [#8424](https://github.com/netbox-community/netbox/issues/8424) - Include rack elevation under device view
* [#10352](https://github.com/netbox-community/netbox/issues/10352) - Omit extraneous URL query attributes during search
* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
### Bug Fixes
* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
* [#10423](https://github.com/netbox-community/netbox/issues/10423) - Enforce object type validation when creating journal entries
* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
* [#10460](https://github.com/netbox-community/netbox/issues/10460) - Restore missing connection details for device components
* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
* [#10491](https://github.com/netbox-community/netbox/issues/10491) - Clarify representation of blocking contact assignments during contact deletion
* [#10513](https://github.com/netbox-community/netbox/issues/10513) - Disable the reassignment of a module to a new device
* [#10517](https://github.com/netbox-community/netbox/issues/10517) - Automatically inherit site assignment from cluster when creating a virtual machine
* [#10559](https://github.com/netbox-community/netbox/issues/10559) - Permit the pinning of a VM to a particular device within a cluster which has no site assignment
* [#10562](https://github.com/netbox-community/netbox/issues/10562) - Correct URL for contacts table tags column
--- ---

View File

@ -0,0 +1,56 @@
# NetBox v3.4
!!! warning "PostgreSQL 11 Required"
NetBox v3.4 requires PostgreSQL 11 or later.
### Breaking Changes
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
### New Features
#### Top-Level Plugin Navigation Menus ([#9071](https://github.com/netbox-community/netbox/issues/9071))
A new `PluginMenu` class has been introduced, which enables a plugin to inject a top-level menu in NetBox's navigation menu. This menu can have one or more groups of menu items, just like core items. Backward compatibility with the existing `menu_items` has been maintained.
### Enhancements
* [#9249](https://github.com/netbox-community/netbox/issues/9249) - Device and virtual machine names are no longer case-sensitive
* [#9478](https://github.com/netbox-community/netbox/issues/9478) - Add `link_peers` field to GraphQL types for cabled objects
* [#9654](https://github.com/netbox-community/netbox/issues/9654) - Add `weight` field to racks, device types, and module types
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
### Plugins API
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Introduce `PluginMenu` for top-level plugin navigation menus
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Introduce `django_apps` plugin configuration parameter
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
### Other Changes
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
### REST API Changes
* circuits.provider
* Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields
* dcim.DeviceType
* Added optional `weight` and `weight_unit` fields
* dcim.ModuleType
* Added optional `weight` and `weight_unit` fields
* dcim.Rack
* Added optional `weight` and `weight_unit` fields
* ipam.FHRPGroup
* Added optional `name` field
### GraphQL API Changes
* All object types now include a `display` field
* All cabled object types now include a `link_peers` field

View File

@ -38,7 +38,6 @@ plugins:
show_root_toc_entry: false show_root_toc_entry: false
show_source: false show_source: false
extra: extra:
readthedocs: !ENV READTHEDOCS
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox link: https://github.com/netbox-community/netbox
@ -252,6 +251,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md' - git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes: - Release Notes:
- Summary: 'release-notes/index.md' - Summary: 'release-notes/index.md'
- Version 3.4: 'release-notes/version-3.4.md'
- Version 3.3: 'release-notes/version-3.3.md' - Version 3.3: 'release-notes/version-3.3.md'
- Version 3.2: 'release-notes/version-3.2.md' - Version 3.2: 'release-notes/version-3.2.md'
- Version 3.1: 'release-notes/version-3.1.md' - Version 3.1: 'release-notes/version-3.1.md'

View File

@ -31,7 +31,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'id', 'url', 'display', 'name', 'slug', 'account',
'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
] ]

View File

@ -65,7 +65,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta: class Meta:
model = Provider model = Provider
fields = ['id', 'name', 'slug', 'asn', 'account'] fields = ['id', 'name', 'slug', 'account']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -73,8 +73,6 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(account__icontains=value) | Q(account__icontains=value) |
Q(noc_contact__icontains=value) |
Q(admin_contact__icontains=value) |
Q(comments__icontains=value) Q(comments__icontains=value)
) )

View File

@ -20,10 +20,6 @@ __all__ = (
class ProviderBulkEditForm(NetBoxModelBulkEditForm): class ProviderBulkEditForm(NetBoxModelBulkEditForm):
asn = forms.IntegerField(
required=False,
label='ASN (legacy)'
)
asns = DynamicModelMultipleChoiceField( asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(), queryset=ASN.objects.all(),
label=_('ASNs'), label=_('ASNs'),
@ -34,20 +30,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
label='Account number' label='Account number'
) )
portal_url = forms.URLField(
required=False,
label='Portal'
)
noc_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='NOC contact'
)
admin_contact = forms.CharField(
required=False,
widget=SmallTextarea,
label='Admin contact'
)
comments = CommentField( comments = CommentField(
widget=SmallTextarea, widget=SmallTextarea,
label='Comments' label='Comments'
@ -55,10 +37,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
model = Provider model = Provider
fieldsets = ( fieldsets = (
(None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), (None, ('asns', 'account', )),
) )
nullable_fields = ( nullable_fields = (
'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'asns', 'account', 'comments',
) )

View File

@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm):
class Meta: class Meta:
model = Provider model = Provider
fields = ( fields = (
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'name', 'slug', 'account', 'comments',
) )

View File

@ -30,29 +30,17 @@ class ProviderForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), ('Provider', ('name', 'slug', 'asns', 'tags')),
('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), ('Support Info', ('account',)),
) )
class Meta: class Meta:
model = Provider model = Provider
fields = [ fields = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', 'name', 'slug', 'account', 'asns', 'comments', 'tags',
] ]
widgets = {
'noc_contact': SmallTextarea(
attrs={'rows': 5}
),
'admin_contact': SmallTextarea(
attrs={'rows': 5}
),
}
help_texts = { help_texts = {
'name': "Full name of the provider", 'name': "Full name of the provider",
'asn': "BGP autonomous system number (if applicable)",
'portal_url': "URL of the provider's customer support portal",
'noc_contact': "NOC email address and phone number",
'admin_contact': "Administrative contact email address and phone number",
} }

View File

@ -1,5 +1,5 @@
import dcim.fields import dcim.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('cid', models.CharField(max_length=100)), ('cid', models.CharField(max_length=100)),
('status', models.CharField(default='active', max_length=50)), ('status', models.CharField(default='active', max_length=50)),
@ -58,7 +58,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -73,7 +73,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -93,7 +93,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)), ('description', models.CharField(blank=True, max_length=200)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import taggit.managers import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='circuittermination', model_name='circuittermination',
name='custom_field_data', name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
), ),
migrations.AddField( migrations.AddField(
model_name='circuittermination', model_name='circuittermination',

View File

@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0038_cabling_cleanup'),
]
operations = [
migrations.RemoveConstraint(
model_name='providernetwork',
name='circuits_providernetwork_provider_name',
),
migrations.AlterUniqueTogether(
name='circuit',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='circuittermination',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='providernetwork',
unique_together=set(),
),
migrations.AddConstraint(
model_name='circuit',
constraint=models.UniqueConstraint(fields=('provider', 'cid'), name='circuits_circuit_unique_provider_cid'),
),
migrations.AddConstraint(
model_name='circuittermination',
constraint=models.UniqueConstraint(fields=('circuit', 'term_side'), name='circuits_circuittermination_unique_circuit_term_side'),
),
migrations.AddConstraint(
model_name='providernetwork',
constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_providernetwork_unique_provider_name'),
),
]

View File

@ -0,0 +1,59 @@
import os
from django.db import migrations
from django.db.utils import DataError
def check_legacy_data(apps, schema_editor):
"""
Abort the migration if any legacy provider fields still contain data.
"""
Provider = apps.get_model('circuits', 'Provider')
provider_count = Provider.objects.exclude(asn__isnull=True).count()
if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ:
raise DataError(
f"Unable to proceed with deleting asn field from Provider model: Found {provider_count} "
f"providers with legacy ASN data. Please ensure all legacy provider ASN data has been "
f"migrated to ASN objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA "
f"environment variable to bypass this safeguard and delete all legacy provider ASN data."
)
provider_count = Provider.objects.exclude(admin_contact='', noc_contact='', portal_url='').count()
if provider_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ:
raise DataError(
f"Unable to proceed with deleting contact fields from Provider model: Found {provider_count} "
f"providers with legacy contact data. Please ensure all legacy provider contact data has been "
f"migrated to contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA "
f"environment variable to bypass this safeguard and delete all legacy provider contact data."
)
class Migration(migrations.Migration):
dependencies = [
('circuits', '0039_unique_constraints'),
]
operations = [
migrations.RunPython(
code=check_legacy_data,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='provider',
name='admin_contact',
),
migrations.RemoveField(
model_name='provider',
name='asn',
),
migrations.RemoveField(
model_name='provider',
name='noc_contact',
),
migrations.RemoveField(
model_name='provider',
name='portal_url',
),
]

View File

@ -132,7 +132,12 @@ class Circuit(NetBoxModel):
class Meta: class Meta:
ordering = ['provider', 'cid'] ordering = ['provider', 'cid']
unique_together = ['provider', 'cid'] constraints = (
models.UniqueConstraint(
fields=('provider', 'cid'),
name='%(app_label)s_%(class)s_unique_provider_cid'
),
)
def __str__(self): def __str__(self):
return self.cid return self.cid
@ -208,7 +213,12 @@ class CircuitTermination(
class Meta: class Meta:
ordering = ['circuit', 'term_side'] ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side'] constraints = (
models.UniqueConstraint(
fields=('circuit', 'term_side'),
name='%(app_label)s_%(class)s_unique_circuit_term_side'
),
)
def __str__(self): def __str__(self):
return f'Termination {self.term_side}: {self.site or self.provider_network}' return f'Termination {self.term_side}: {self.site or self.provider_network}'

View File

@ -24,12 +24,6 @@ class Provider(NetBoxModel):
max_length=100, max_length=100,
unique=True unique=True
) )
asn = ASNField(
blank=True,
null=True,
verbose_name='ASN',
help_text='32-bit autonomous system number'
)
asns = models.ManyToManyField( asns = models.ManyToManyField(
to='ipam.ASN', to='ipam.ASN',
related_name='providers', related_name='providers',
@ -40,18 +34,6 @@ class Provider(NetBoxModel):
blank=True, blank=True,
verbose_name='Account number' verbose_name='Account number'
) )
portal_url = models.URLField(
blank=True,
verbose_name='Portal URL'
)
noc_contact = models.TextField(
blank=True,
verbose_name='NOC contact'
)
admin_contact = models.TextField(
blank=True,
verbose_name='Admin contact'
)
comments = models.TextField( comments = models.TextField(
blank=True blank=True
) )
@ -62,7 +44,7 @@ class Provider(NetBoxModel):
) )
clone_fields = ( clone_fields = (
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'account',
) )
class Meta: class Meta:
@ -106,10 +88,9 @@ class ProviderNetwork(NetBoxModel):
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('provider', 'name'), fields=('provider', 'name'),
name='circuits_providernetwork_provider_name' name='%(app_label)s_%(class)s_unique_provider_name'
), ),
) )
unique_together = ('provider', 'name')
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -41,10 +41,10 @@ class ProviderTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', 'pk', 'id', 'name', 'asns', 'account', 'asn_count',
'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') default_columns = ('pk', 'name', 'account', 'circuit_count')
class ProviderNetworkTable(NetBoxTable): class ProviderNetworkTable(NetBoxTable):

View File

@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider model = Provider
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'asn': 1234, 'account': '1234',
} }
@classmethod @classmethod

View File

@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 1', slug='provider-1', account='1234'),
Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), Provider(name='Provider 2', slug='provider-2', account='2345'),
Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'), Provider(name='Provider 3', slug='provider-3', account='3456'),
Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'), Provider(name='Provider 4', slug='provider-4', account='4567'),
Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), Provider(name='Provider 5', slug='provider-5', account='5678'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0]]) providers[0].asns.set([asns[0]])
@ -82,10 +82,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-2']} params = {'slug': ['provider-1', 'provider-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn(self): # Legacy field
params = {'asn': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn_id(self): # ASN object assignment def test_asn_id(self): # ASN object assignment
asns = ASN.objects.all()[:2] asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]} params = {'asn_id': [asns[0].pk, asns[1].pk]}

View File

@ -23,9 +23,9 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3', asn=65003), Provider(name='Provider 3', slug='provider-3'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0], asns[1]]) providers[0].asns.set([asns[0], asns[1]])
@ -37,12 +37,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'name': 'Provider X', 'name': 'Provider X',
'slug': 'provider-x', 'slug': 'provider-x',
'asn': 65123,
'asns': [asns[6].pk, asns[7].pk], 'asns': [asns[6].pk, asns[7].pk],
'account': '1234', 'account': '1234',
'portal_url': 'http://example.com/portal',
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider', 'comments': 'Another provider',
'tags': [t.pk for t in tags], 'tags': [t.pk for t in tags],
} }
@ -55,11 +51,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'asn': 65009,
'account': '5678', 'account': '5678',
'portal_url': 'http://example.com/portal2',
'noc_contact': 'noc2@example.com',
'admin_contact': 'admin2@example.com',
'comments': 'New comments', 'comments': 'New comments',
} }
@ -104,8 +96,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls): def setUpTestData(cls):
providers = ( providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 2', slug='provider-2'),
) )
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)

View File

@ -1,9 +1,7 @@
from django.urls import path from django.urls import include, path
from dcim.views import PathTraceView from utilities.urls import get_model_urls
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
from . import views from . import views
from .models import *
app_name = 'circuits' app_name = 'circuits'
urlpatterns = [ urlpatterns = [
@ -14,11 +12,7 @@ urlpatterns = [
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers/<int:pk>/', views.ProviderView.as_view(), name='provider'), path('providers/<int:pk>/', include(get_model_urls('circuits', 'provider'))),
path('providers/<int:pk>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<int:pk>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
# Provider networks # Provider networks
path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'), path('provider-networks/', views.ProviderNetworkListView.as_view(), name='providernetwork_list'),
@ -26,11 +20,7 @@ urlpatterns = [
path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'), path('provider-networks/import/', views.ProviderNetworkBulkImportView.as_view(), name='providernetwork_import'),
path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'), path('provider-networks/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'),
path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'), path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'),
path('provider-networks/<int:pk>/', views.ProviderNetworkView.as_view(), name='providernetwork'), path('provider-networks/<int:pk>/', include(get_model_urls('circuits', 'providernetwork'))),
path('provider-networks/<int:pk>/edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'),
path('provider-networks/<int:pk>/delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'),
path('provider-networks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}),
path('provider-networks/<int:pk>/journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}),
# Circuit types # Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
@ -38,10 +28,7 @@ urlpatterns = [
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<int:pk>/', views.CircuitTypeView.as_view(), name='circuittype'), path('circuit-types/<int:pk>/', include(get_model_urls('circuits', 'circuittype'))),
path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
path('circuit-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits # Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
@ -49,17 +36,11 @@ urlpatterns = [
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations # Circuit terminations
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:pk>/trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
] ]

View File

@ -3,9 +3,11 @@ from django.db import transaction
from django.db.models import Q from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from dcim.views import PathTraceView
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
@ -23,6 +25,7 @@ class ProviderListView(generic.ObjectListView):
table = tables.ProviderTable table = tables.ProviderTable
@register_model_view(Provider)
class ProviderView(generic.ObjectView): class ProviderView(generic.ObjectView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
@ -41,11 +44,13 @@ class ProviderView(generic.ObjectView):
} }
@register_model_view(Provider, 'edit')
class ProviderEditView(generic.ObjectEditView): class ProviderEditView(generic.ObjectEditView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
form = forms.ProviderForm form = forms.ProviderForm
@register_model_view(Provider, 'delete')
class ProviderDeleteView(generic.ObjectDeleteView): class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all() queryset = Provider.objects.all()
@ -84,6 +89,7 @@ class ProviderNetworkListView(generic.ObjectListView):
table = tables.ProviderNetworkTable table = tables.ProviderNetworkTable
@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView): class ProviderNetworkView(generic.ObjectView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
@ -103,11 +109,13 @@ class ProviderNetworkView(generic.ObjectView):
} }
@register_model_view(ProviderNetwork, 'edit')
class ProviderNetworkEditView(generic.ObjectEditView): class ProviderNetworkEditView(generic.ObjectEditView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
form = forms.ProviderNetworkForm form = forms.ProviderNetworkForm
@register_model_view(ProviderNetwork, 'delete')
class ProviderNetworkDeleteView(generic.ObjectDeleteView): class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
@ -144,6 +152,7 @@ class CircuitTypeListView(generic.ObjectListView):
table = tables.CircuitTypeTable table = tables.CircuitTypeTable
@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView): class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
@ -157,11 +166,13 @@ class CircuitTypeView(generic.ObjectView):
} }
@register_model_view(CircuitType, 'edit')
class CircuitTypeEditView(generic.ObjectEditView): class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
form = forms.CircuitTypeForm form = forms.CircuitTypeForm
@register_model_view(CircuitType, 'delete')
class CircuitTypeDeleteView(generic.ObjectDeleteView): class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all() queryset = CircuitType.objects.all()
@ -202,15 +213,18 @@ class CircuitListView(generic.ObjectListView):
table = tables.CircuitTable table = tables.CircuitTable
@register_model_view(Circuit)
class CircuitView(generic.ObjectView): class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
@register_model_view(Circuit, 'edit')
class CircuitEditView(generic.ObjectEditView): class CircuitEditView(generic.ObjectEditView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
form = forms.CircuitForm form = forms.CircuitForm
@register_model_view(Circuit, 'delete')
class CircuitDeleteView(generic.ObjectDeleteView): class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
@ -318,11 +332,17 @@ class CircuitSwapTerminations(generic.ObjectEditView):
# Circuit terminations # Circuit terminations
# #
@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
form = forms.CircuitTerminationForm form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html' template_name = 'circuits/circuittermination_edit.html'
@register_model_view(CircuitTermination, 'delete')
class CircuitTerminationDeleteView(generic.ObjectDeleteView): class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all() queryset = CircuitTermination.objects.all()
# Trace view
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)

View File

@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer):
default=None) default=None)
width = ChoiceField(choices=RackWidthChoices, required=False) width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True)
@ -208,8 +209,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack model = Rack
fields = [ fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial', 'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', 'outer_depth', 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
] ]
@ -315,27 +317,29 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
) )
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
device_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
'last_updated', 'device_count', 'custom_fields', 'created', 'last_updated', 'device_count',
] ]
class ModuleTypeSerializer(NetBoxModelSerializer): class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
# module_count = serializers.IntegerField(read_only=True) # module_count = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
'created', 'last_updated', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -1314,6 +1314,24 @@ class CableLengthUnitChoices(ChoiceSet):
) )
class WeightUnitChoices(ChoiceSet):
# Metric
UNIT_KILOGRAM = 'kg'
UNIT_GRAM = 'g'
# Imperial
UNIT_POUND = 'lb'
UNIT_OUNCE = 'oz'
CHOICES = (
(UNIT_KILOGRAM, 'Kilograms'),
(UNIT_GRAM, 'Grams'),
(UNIT_POUND, 'Pounds'),
(UNIT_OUNCE, 'Ounces'),
)
# #
# CableTerminations # CableTerminations
# #

View File

@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack model = Rack
fields = [ fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'outer_unit', 'weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = DeviceType model = DeviceType
fields = [ fields = [
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = ['id', 'model', 'part_number'] fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -887,6 +887,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug', to_field_name='slug',
label='Device model (slug)', label='Device model (slug)',
) )
name = MultiValueCharFilter(
lookup_expr='iexact'
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices, choices=DeviceStatusChoices,
null_value=None null_value=None
@ -950,7 +953,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta: class Meta:
model = Device model = Device
fields = ['id', 'name', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
widget=SmallTextarea, widget=SmallTextarea,
label='Comments' label='Comments'
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = Rack model = Rack
fieldsets = ( fieldsets = (
('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
('Location', ('region', 'site_group', 'site', 'location')), ('Location', ('region', 'site_group', 'site', 'location')),
('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', 'weight', 'weight_unit'
) )
@ -355,12 +366,23 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = DeviceType model = DeviceType
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ('part_number', 'airflow') nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit')
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
part_number = forms.CharField( part_number = forms.CharField(
required=False required=False
) )
weight = forms.DecimalField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial='',
widget=StaticSelect()
)
model = ModuleType model = ModuleType
fieldsets = ( fieldsets = (
(None, ('manufacturer', 'part_number')), ('Module Type', ('manufacturer', 'part_number')),
('Weight', ('weight', 'weight_unit')),
) )
nullable_fields = ('part_number',) nullable_fields = ('part_number', 'weight', 'weight_unit')
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
'type', 'status', 'tenant', 'label', 'color', 'length', 'type', 'status', 'tenant', 'label', 'color', 'length',
) )
def clean(self):
super().clean()
# Validate length/unit
length = self.cleaned_data.get('length')
length_unit = self.cleaned_data.get('length_unit')
if length and not length_unit:
raise forms.ValidationError({
'length_unit': "Must specify a unit when setting length"
})
class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
domain = forms.CharField( domain = forms.CharField(

View File

@ -228,6 +228,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')), ('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')), ('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'weight_unit')),
) )
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False required=False
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackElevationFilterForm(RackFilterForm): class RackElevationFilterForm(RackFilterForm):
@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)), )),
('Weight', ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class ModuleTypeFilterForm(NetBoxModelFilterSetForm): class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'pass_through_ports',
)), )),
('Weight', ('weight', 'weight_unit')),
) )
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
) )
) )
tag = TagFilterField(model) tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class DeviceRoleFilterForm(NetBoxModelFilterSetForm): class DeviceRoleFilterForm(NetBoxModelFilterSetForm):

View File

@ -260,7 +260,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'comments', 'tags', 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags',
] ]
help_texts = { help_texts = {
'site': "The site at which the rack exists", 'site': "The site at which the rack exists",
@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
'type': StaticSelect(), 'type': StaticSelect(),
'width': StaticSelect(), 'width': StaticSelect(),
'outer_unit': StaticSelect(), 'outer_unit': StaticSelect(),
'weight_unit': StaticSelect(),
} }
@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm):
('Chassis', ( ('Chassis', (
'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
)), )),
('Attributes', ('weight', 'weight_unit')),
('Images', ('front_image', 'rear_image')), ('Images', ('front_image', 'rear_image')),
) )
@ -370,16 +372,18 @@ class DeviceTypeForm(NetBoxModelForm):
model = DeviceType model = DeviceType
fields = [ fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
'front_image', 'rear_image', 'comments', 'tags', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
] ]
widgets = { widgets = {
'airflow': StaticSelect(),
'subdevice_role': StaticSelect(), 'subdevice_role': StaticSelect(),
'front_image': ClearableFileInput(attrs={ 'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS 'accept': DEVICETYPE_IMAGE_FORMATS
}), }),
'rear_image': ClearableFileInput(attrs={ 'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS 'accept': DEVICETYPE_IMAGE_FORMATS
}) }),
'weight_unit': StaticSelect(),
} }
@ -391,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
('Module Type', ( ('Module Type', (
'manufacturer', 'model', 'part_number', 'tags', 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit'
)), )),
) )
class Meta: class Meta:
model = ModuleType model = ModuleType
fields = [ fields = [
'manufacturer', 'model', 'part_number', 'comments', 'tags', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
] ]
widgets = {
'weight_unit': StaticSelect(),
}
class DeviceRoleForm(NetBoxModelForm): class DeviceRoleForm(NetBoxModelForm):
slug = SlugField() slug = SlugField()
@ -678,6 +686,7 @@ class ModuleForm(NetBoxModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance.pk: if self.instance.pk:
self.fields['device'].disabled = True
self.fields['replicate_components'].initial = False self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True self.fields['replicate_components'].disabled = True
self.fields['adopt_components'].initial = False self.fields['adopt_components'].initial = False

View File

@ -0,0 +1,59 @@
import graphene
from circuits.graphql.types import CircuitTerminationType
from circuits.models import CircuitTermination
from dcim.graphql.types import (
ConsolePortType,
ConsoleServerPortType,
FrontPortType,
InterfaceType,
PowerFeedType,
PowerOutletType,
PowerPortType,
RearPortType,
)
from dcim.models import (
ConsolePort,
ConsoleServerPort,
FrontPort,
Interface,
PowerFeed,
PowerOutlet,
PowerPort,
RearPort,
)
class LinkPeerType(graphene.Union):
class Meta:
types = (
CircuitTerminationType,
ConsolePortType,
ConsoleServerPortType,
FrontPortType,
InterfaceType,
PowerFeedType,
PowerOutletType,
PowerPortType,
RearPortType,
)
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination:
return CircuitTerminationType
if type(instance) == ConsolePortType:
return ConsolePortType
if type(instance) == ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
return FrontPortType
if type(instance) == Interface:
return InterfaceType
if type(instance) == PowerFeed:
return PowerFeedType
if type(instance) == PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
return PowerPortType
if type(instance) == RearPort:
return RearPortType

View File

@ -1,5 +1,12 @@
import graphene
class CabledObjectMixin: class CabledObjectMixin:
link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
def resolve_cable_end(self, info): def resolve_cable_end(self, info):
# Handle empty values # Handle empty values
return self.cable_end or None return self.cable_end or None
def resolve_link_peers(self, info):
return self.link_peers

View File

@ -211,6 +211,9 @@ class DeviceTypeType(NetBoxObjectType):
def resolve_airflow(self, info): def resolve_airflow(self, info):
return self.airflow or None return self.airflow or None
def resolve_weight_unit(self, info):
return self.weight_unit or None
class FrontPortType(ComponentObjectType, CabledObjectMixin): class FrontPortType(ComponentObjectType, CabledObjectMixin):
@ -328,6 +331,9 @@ class ModuleTypeType(NetBoxObjectType):
fields = '__all__' fields = '__all__'
filterset_class = filtersets.ModuleTypeFilterSet filterset_class = filtersets.ModuleTypeFilterSet
def resolve_weight_unit(self, info):
return self.weight_unit or None
class PlatformType(OrganizationalObjectType): class PlatformType(OrganizationalObjectType):
@ -416,6 +422,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
def resolve_outer_unit(self, info): def resolve_outer_unit(self, info):
return self.outer_unit or None return self.outer_unit or None
def resolve_weight_unit(self, info):
return self.weight_unit or None
class RackReservationType(NetBoxObjectType): class RackReservationType(NetBoxObjectType):

View File

@ -1,6 +1,6 @@
import dcim.fields import dcim.fields
import django.contrib.postgres.fields import django.contrib.postgres.fields
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
import django.core.validators import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -28,7 +28,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('termination_a_id', models.PositiveIntegerField()), ('termination_a_id', models.PositiveIntegerField()),
('termination_b_id', models.PositiveIntegerField()), ('termination_b_id', models.PositiveIntegerField()),
@ -60,7 +60,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -96,7 +96,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -132,7 +132,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('local_context_data', models.JSONField(blank=True, null=True)), ('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(blank=True, max_length=64, null=True)), ('name', models.CharField(blank=True, max_length=64, null=True)),
@ -155,7 +155,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -186,7 +186,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -203,7 +203,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('model', models.CharField(max_length=100)), ('model', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -224,7 +224,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -261,7 +261,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('label', models.CharField(blank=True, max_length=64)), ('label', models.CharField(blank=True, max_length=64)),
@ -302,7 +302,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -326,7 +326,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)), ('slug', models.SlugField(max_length=100)),
@ -345,7 +345,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -360,7 +360,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -377,7 +377,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)), ('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
('mark_connected', models.BooleanField(default=False)), ('mark_connected', models.BooleanField(default=False)),
@ -401,7 +401,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -438,7 +438,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
], ],
@ -451,7 +451,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -490,7 +490,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100)), ('name', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -516,7 +516,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)), ('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('description', models.CharField(max_length=200)), ('description', models.CharField(max_length=200)),
@ -530,7 +530,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -546,7 +546,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -583,7 +583,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -602,7 +602,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -630,7 +630,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
@ -649,7 +649,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('domain', models.CharField(blank=True, max_length=30)), ('domain', models.CharField(blank=True, max_length=30)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -107,7 +107,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('model', models.CharField(max_length=100)), ('model', models.CharField(max_length=100)),
('part_number', models.CharField(blank=True, max_length=50)), ('part_number', models.CharField(blank=True, max_length=50)),
@ -125,7 +125,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)), ('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@ -145,7 +145,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('local_context_data', models.JSONField(blank=True, null=True)), ('local_context_data', models.JSONField(blank=True, null=True)),
('serial', models.CharField(blank=True, max_length=50)), ('serial', models.CharField(blank=True, max_length=50)),

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import taggit.managers import taggit.managers
@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('created', models.DateField(auto_now_add=True, null=True)), ('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder)),
('id', models.BigAutoField(primary_key=True, serialize=False)), ('id', models.BigAutoField(primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),

View File

@ -0,0 +1,332 @@
from django.db import migrations, models
import django.db.models.functions.text
class Migration(migrations.Migration):
dependencies = [
('dcim', '0161_cabling_cleanup'),
]
operations = [
migrations.RemoveConstraint(
model_name='cabletermination',
name='dcim_cable_termination_unique_termination',
),
migrations.RemoveConstraint(
model_name='location',
name='dcim_location_name',
),
migrations.RemoveConstraint(
model_name='location',
name='dcim_location_slug',
),
migrations.RemoveConstraint(
model_name='region',
name='dcim_region_name',
),
migrations.RemoveConstraint(
model_name='region',
name='dcim_region_slug',
),
migrations.RemoveConstraint(
model_name='sitegroup',
name='dcim_sitegroup_name',
),
migrations.RemoveConstraint(
model_name='sitegroup',
name='dcim_sitegroup_slug',
),
migrations.AlterUniqueTogether(
name='consoleport',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='consoleporttemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='consoleserverport',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='consoleserverporttemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='device',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='devicebay',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='devicebaytemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='devicetype',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='frontport',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='frontporttemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='interface',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='interfacetemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='inventoryitem',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='inventoryitemtemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='modulebay',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='modulebaytemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='moduletype',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='powerfeed',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='poweroutlet',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='poweroutlettemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='powerpanel',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='powerport',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='powerporttemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='rack',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='rearport',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='rearporttemplate',
unique_together=set(),
),
migrations.AddConstraint(
model_name='cabletermination',
constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cabletermination_unique_termination'),
),
migrations.AddConstraint(
model_name='consoleport',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleport_unique_device_name'),
),
migrations.AddConstraint(
model_name='consoleporttemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleporttemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='consoleporttemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleporttemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='consoleserverport',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_consoleserverport_unique_device_name'),
),
migrations.AddConstraint(
model_name='consoleserverporttemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_consoleserverporttemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='consoleserverporttemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_consoleserverporttemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('name'), models.F('site'), condition=models.Q(('tenant__isnull', True)), name='dcim_device_unique_name_site', violation_error_message='Device name must be unique per site.'),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(fields=('rack', 'position', 'face'), name='dcim_device_unique_rack_position_face'),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(fields=('virtual_chassis', 'vc_position'), name='dcim_device_unique_virtual_chassis_vc_position'),
),
migrations.AddConstraint(
model_name='devicebay',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_devicebay_unique_device_name'),
),
migrations.AddConstraint(
model_name='devicebaytemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_devicebaytemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='devicetype',
constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_devicetype_unique_manufacturer_model'),
),
migrations.AddConstraint(
model_name='devicetype',
constraint=models.UniqueConstraint(fields=('manufacturer', 'slug'), name='dcim_devicetype_unique_manufacturer_slug'),
),
migrations.AddConstraint(
model_name='frontport',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_frontport_unique_device_name'),
),
migrations.AddConstraint(
model_name='frontport',
constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontport_unique_rear_port_position'),
),
migrations.AddConstraint(
model_name='frontporttemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_frontporttemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='frontporttemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_frontporttemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='frontporttemplate',
constraint=models.UniqueConstraint(fields=('rear_port', 'rear_port_position'), name='dcim_frontporttemplate_unique_rear_port_position'),
),
migrations.AddConstraint(
model_name='interface',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_interface_unique_device_name'),
),
migrations.AddConstraint(
model_name='interfacetemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_interfacetemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='interfacetemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_interfacetemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='inventoryitem',
constraint=models.UniqueConstraint(fields=('device', 'parent', 'name'), name='dcim_inventoryitem_unique_device_parent_name'),
),
migrations.AddConstraint(
model_name='inventoryitemtemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'parent', 'name'), name='dcim_inventoryitemtemplate_unique_device_type_parent_name'),
),
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'name'), name='dcim_location_name', violation_error_message='A location with this name already exists within the specified site.'),
),
migrations.AddConstraint(
model_name='location',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('site', 'slug'), name='dcim_location_slug', violation_error_message='A location with this slug already exists within the specified site.'),
),
migrations.AddConstraint(
model_name='modulebay',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_modulebay_unique_device_name'),
),
migrations.AddConstraint(
model_name='modulebaytemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_modulebaytemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='moduletype',
constraint=models.UniqueConstraint(fields=('manufacturer', 'model'), name='dcim_moduletype_unique_manufacturer_model'),
),
migrations.AddConstraint(
model_name='powerfeed',
constraint=models.UniqueConstraint(fields=('power_panel', 'name'), name='dcim_powerfeed_unique_power_panel_name'),
),
migrations.AddConstraint(
model_name='poweroutlet',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_poweroutlet_unique_device_name'),
),
migrations.AddConstraint(
model_name='poweroutlettemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_poweroutlettemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='poweroutlettemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_poweroutlettemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='powerpanel',
constraint=models.UniqueConstraint(fields=('site', 'name'), name='dcim_powerpanel_unique_site_name'),
),
migrations.AddConstraint(
model_name='powerport',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_powerport_unique_device_name'),
),
migrations.AddConstraint(
model_name='powerporttemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_powerporttemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='powerporttemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_powerporttemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='rack',
constraint=models.UniqueConstraint(fields=('location', 'name'), name='dcim_rack_unique_location_name'),
),
migrations.AddConstraint(
model_name='rack',
constraint=models.UniqueConstraint(fields=('location', 'facility_id'), name='dcim_rack_unique_location_facility_id'),
),
migrations.AddConstraint(
model_name='rearport',
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_rearport_unique_device_name'),
),
migrations.AddConstraint(
model_name='rearporttemplate',
constraint=models.UniqueConstraint(fields=('device_type', 'name'), name='dcim_rearporttemplate_unique_device_type_name'),
),
migrations.AddConstraint(
model_name='rearporttemplate',
constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_rearporttemplate_unique_module_type_name'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_region_name', violation_error_message='A top-level region with this name already exists.'),
),
migrations.AddConstraint(
model_name='region',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_region_slug', violation_error_message='A top-level region with this slug already exists.'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('name',), name='dcim_sitegroup_name', violation_error_message='A top-level site group with this name already exists.'),
),
migrations.AddConstraint(
model_name='sitegroup',
constraint=models.UniqueConstraint(condition=models.Q(('parent__isnull', True)), fields=('slug',), name='dcim_sitegroup_slug', violation_error_message='A top-level site group with this slug already exists.'),
),
]

View File

@ -0,0 +1,58 @@
# Generated by Django 4.0.7 on 2022-09-23 01:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0162_unique_constraints'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='devicetype',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='devicetype',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='moduletype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='moduletype',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='moduletype',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='rack',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -269,7 +269,7 @@ class CableTermination(models.Model):
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('termination_type', 'termination_id'), fields=('termination_type', 'termination_id'),
name='dcim_cable_termination_unique_termination' name='%(app_label)s_%(class)s_unique_termination'
), ),
) )

View File

@ -61,6 +61,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device_type', '_name')
constraints = (
models.UniqueConstraint(
fields=('device_type', 'name'),
name='%(app_label)s_%(class)s_unique_device_type_name'
),
)
def __str__(self): def __str__(self):
if self.label: if self.label:
@ -100,6 +107,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device_type', 'module_type', '_name')
constraints = (
models.UniqueConstraint(
fields=('device_type', 'name'),
name='%(app_label)s_%(class)s_unique_device_type_name'
),
models.UniqueConstraint(
fields=('module_type', 'name'),
name='%(app_label)s_%(class)s_unique_module_type_name'
),
)
def to_objectchange(self, action): def to_objectchange(self, action):
objectchange = super().to_objectchange(action) objectchange = super().to_objectchange(action)
@ -145,13 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
component_model = ConsolePort component_model = ConsolePort
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -181,13 +192,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
component_model = ConsoleServerPort component_model = ConsoleServerPort
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -229,13 +233,6 @@ class PowerPortTemplate(ModularComponentTemplateModel):
component_model = PowerPort component_model = PowerPort
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -291,13 +288,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
component_model = PowerOutlet component_model = PowerOutlet
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def clean(self): def clean(self):
super().clean() super().clean()
@ -372,13 +362,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
component_model = Interface component_model = Interface
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -428,12 +411,20 @@ class FrontPortTemplate(ModularComponentTemplateModel):
component_model = FrontPort component_model = FrontPort
class Meta: class Meta(ModularComponentTemplateModel.Meta):
ordering = ('device_type', 'module_type', '_name') constraints = (
unique_together = ( models.UniqueConstraint(
('device_type', 'name'), fields=('device_type', 'name'),
('module_type', 'name'), name='%(app_label)s_%(class)s_unique_device_type_name'
('rear_port', 'rear_port_position'), ),
models.UniqueConstraint(
fields=('module_type', 'name'),
name='%(app_label)s_%(class)s_unique_module_type_name'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
) )
def clean(self): def clean(self):
@ -507,13 +498,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
component_model = RearPort component_model = RearPort
class Meta:
ordering = ('device_type', 'module_type', '_name')
unique_together = (
('device_type', 'name'),
('module_type', 'name'),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
return self.component_model( return self.component_model(
name=self.resolve_name(kwargs.get('module')), name=self.resolve_name(kwargs.get('module')),
@ -547,10 +531,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay component_model = ModuleBay
class Meta:
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def instantiate(self, device): def instantiate(self, device):
return self.component_model( return self.component_model(
device=device, device=device,
@ -574,10 +554,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
""" """
component_model = DeviceBay component_model = DeviceBay
class Meta:
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def instantiate(self, device): def instantiate(self, device):
return self.component_model( return self.component_model(
device=device, device=device,
@ -653,7 +629,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
class Meta: class Meta:
ordering = ('device_type__id', 'parent__id', '_name') ordering = ('device_type__id', 'parent__id', '_name')
unique_together = ('device_type', 'parent', 'name') constraints = (
models.UniqueConstraint(
fields=('device_type', 'parent', 'name'),
name='%(app_label)s_%(class)s_unique_device_type_parent_name'
),
)
def instantiate(self, **kwargs): def instantiate(self, **kwargs):
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None

View File

@ -69,6 +69,13 @@ class ComponentModel(NetBoxModel):
class Meta: class Meta:
abstract = True abstract = True
ordering = ('device', '_name')
constraints = (
models.UniqueConstraint(
fields=('device', 'name'),
name='%(app_label)s_%(class)s_unique_device_name'
),
)
def __str__(self): def __str__(self):
if self.label: if self.label:
@ -99,7 +106,7 @@ class ModularComponentModel(ComponentModel):
object_id_field='component_id' object_id_field='component_id'
) )
class Meta: class Meta(ComponentModel.Meta):
abstract = True abstract = True
@ -265,10 +272,6 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed') clone_fields = ('device', 'module', 'type', 'speed')
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:consoleport', kwargs={'pk': self.pk}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
@ -292,10 +295,6 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed') clone_fields = ('device', 'module', 'type', 'speed')
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk}) return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
@ -329,10 +328,6 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:powerport', kwargs={'pk': self.pk}) return reverse('dcim:powerport', kwargs={'pk': self.pk})
@ -443,10 +438,6 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk}) return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
@ -677,9 +668,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
) )
class Meta: class Meta(ModularComponentModel.Meta):
ordering = ('device', CollateAsChar('_name')) ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk}) return reverse('dcim:interface', kwargs={'pk': self.pk})
@ -895,11 +885,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
clone_fields = ('device', 'type', 'color') clone_fields = ('device', 'type', 'color')
class Meta: class Meta(ModularComponentModel.Meta):
ordering = ('device', '_name') constraints = (
unique_together = ( models.UniqueConstraint(
('device', 'name'), fields=('device', 'name'),
('rear_port', 'rear_port_position'), name='%(app_label)s_%(class)s_unique_device_name'
),
models.UniqueConstraint(
fields=('rear_port', 'rear_port_position'),
name='%(app_label)s_%(class)s_unique_rear_port_position'
),
) )
def get_absolute_url(self): def get_absolute_url(self):
@ -944,10 +939,6 @@ class RearPort(ModularComponentModel, CabledObjectModel):
) )
clone_fields = ('device', 'type', 'color', 'positions') clone_fields = ('device', 'type', 'color', 'positions')
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk}) return reverse('dcim:rearport', kwargs={'pk': self.pk})
@ -955,12 +946,13 @@ class RearPort(ModularComponentModel, CabledObjectModel):
super().clean() super().clean()
# Check that positions count is greater than or equal to the number of associated FrontPorts # Check that positions count is greater than or equal to the number of associated FrontPorts
frontport_count = self.frontports.count() if self.pk:
if self.positions < frontport_count: frontport_count = self.frontports.count()
raise ValidationError({ if self.positions < frontport_count:
"positions": f"The number of positions cannot be less than the number of mapped front ports " raise ValidationError({
f"({frontport_count})" "positions": f"The number of positions cannot be less than the number of mapped front ports "
}) f"({frontport_count})"
})
# #
@ -979,10 +971,6 @@ class ModuleBay(ComponentModel):
clone_fields = ('device',) clone_fields = ('device',)
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
@ -1001,10 +989,6 @@ class DeviceBay(ComponentModel):
clone_fields = ('device',) clone_fields = ('device',)
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:devicebay', kwargs={'pk': self.pk}) return reverse('dcim:devicebay', kwargs={'pk': self.pk})
@ -1140,7 +1124,12 @@ class InventoryItem(MPTTModel, ComponentModel):
class Meta: class Meta:
ordering = ('device__id', 'parent__id', '_name') ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name') constraints = (
models.UniqueConstraint(
fields=('device', 'parent', 'name'),
name='%(app_label)s_%(class)s_unique_device_parent_name'
),
)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk}) return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})

View File

@ -1,13 +1,15 @@
import decimal import decimal
import yaml import yaml
from functools import cached_property
from django.apps import apps from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import F, ProtectedError from django.db.models import F, ProtectedError
from django.db.models.functions import Lower
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -20,6 +22,7 @@ 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 *
from .mixins import WeightMixin
__all__ = ( __all__ = (
@ -70,7 +73,7 @@ class Manufacturer(OrganizationalModel):
return reverse('dcim:manufacturer', args=[self.pk]) return reverse('dcim:manufacturer', args=[self.pk])
class DeviceType(NetBoxModel): class DeviceType(NetBoxModel, WeightMixin):
""" """
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).
@ -138,15 +141,21 @@ class DeviceType(NetBoxModel):
) )
clone_fields = ( clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
) )
class Meta: class Meta:
ordering = ['manufacturer', 'model'] ordering = ['manufacturer', 'model']
unique_together = [ constraints = (
['manufacturer', 'model'], models.UniqueConstraint(
['manufacturer', 'slug'], fields=('manufacturer', 'model'),
] name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_unique_manufacturer_slug'
),
)
def __str__(self): def __str__(self):
return self.model return self.model
@ -268,7 +277,7 @@ class DeviceType(NetBoxModel):
if ( if (
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
) and self.devicebaytemplates.count(): ) and self.pk and self.devicebaytemplates.count():
raise ValidationError({ raise ValidationError({
'subdevice_role': "Must delete all device bay templates associated with this device before " 'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device." "declassifying it as a parent device."
@ -308,7 +317,7 @@ class DeviceType(NetBoxModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
class ModuleType(NetBoxModel): class ModuleType(NetBoxModel, WeightMixin):
""" """
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
@ -337,12 +346,15 @@ class ModuleType(NetBoxModel):
to='extras.ImageAttachment' to='extras.ImageAttachment'
) )
clone_fields = ('manufacturer',) clone_fields = ('manufacturer', 'weight', 'weight_unit',)
class Meta: class Meta:
ordering = ('manufacturer', 'model') ordering = ('manufacturer', 'model')
unique_together = ( constraints = (
('manufacturer', 'model'), models.UniqueConstraint(
fields=('manufacturer', 'model'),
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
) )
def __str__(self): def __str__(self):
@ -651,10 +663,25 @@ class Device(NetBoxModel, ConfigContextModel):
class Meta: class Meta:
ordering = ('_name', 'pk') # Name may be null ordering = ('_name', 'pk') # Name may be null
unique_together = ( constraints = (
('site', 'tenant', 'name'), # See validate_unique below models.UniqueConstraint(
('rack', 'position', 'face'), Lower('name'), 'site', 'tenant',
('virtual_chassis', 'vc_position'), name='%(app_label)s_%(class)s_unique_name_site_tenant'
),
models.UniqueConstraint(
Lower('name'), 'site',
name='%(app_label)s_%(class)s_unique_name_site',
condition=Q(tenant__isnull=True),
violation_error_message="Device name must be unique per site."
),
models.UniqueConstraint(
fields=('rack', 'position', 'face'),
name='%(app_label)s_%(class)s_unique_rack_position_face'
),
models.UniqueConstraint(
fields=('virtual_chassis', 'vc_position'),
name='%(app_label)s_%(class)s_unique_virtual_chassis_vc_position'
),
) )
def __str__(self): def __str__(self):
@ -679,23 +706,6 @@ class Device(NetBoxModel, ConfigContextModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk]) return reverse('dcim:device', args=[self.pk])
def validate_unique(self, exclude=None):
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.name and hasattr(self, 'site') and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(
name=self.name,
site=self.site,
tenant__isnull=True
):
raise ValidationError({
'name': 'A device with this name already exists.'
})
super().validate_unique(exclude)
def clean(self): def clean(self):
super().clean() super().clean()
@ -938,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel):
def get_status_color(self): def get_status_color(self):
return DeviceStatusChoices.colors.get(self.status) return DeviceStatusChoices.colors.get(self.status)
@cached_property
def total_weight(self):
total_weight = sum(
module.module_type._abs_weight
for module in Module.objects.filter(device=self)
.exclude(module_type___abs_weight__isnull=True)
.prefetch_related('module_type')
)
if self.device_type._abs_weight:
total_weight += self.device_type._abs_weight
return round(total_weight / 1000, 2)
class Module(NetBoxModel, ConfigContextModel): class Module(NetBoxModel, ConfigContextModel):
""" """
@ -987,6 +1009,14 @@ class Module(NetBoxModel, ConfigContextModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:module', args=[self.pk]) return reverse('dcim:module', args=[self.pk])
def clean(self):
super().clean()
if self.module_bay.device != self.device:
raise ValidationError(
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self.pk is None is_new = self.pk is None

View File

@ -0,0 +1,45 @@
from django.core.exceptions import ValidationError
from django.db import models
from dcim.choices import *
from utilities.utils import to_grams
class WeightMixin(models.Model):
weight = models.DecimalField(
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
weight_unit = models.CharField(
max_length=50,
choices=WeightUnitChoices,
blank=True,
)
# Stores the normalized weight (in grams) for database ordering
_abs_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
# Store the given weight (if any) in grams for use in database ordering
if self.weight and self.weight_unit:
self._abs_weight = to_grams(self.weight, self.weight_unit)
else:
self._abs_weight = None
super().save(*args, **kwargs)
def clean(self):
super().clean()
# Validate weight and weight_unit
if self.weight is not None and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a weight")
elif self.weight is None:
self.weight_unit = ''

View File

@ -50,7 +50,12 @@ class PowerPanel(NetBoxModel):
class Meta: class Meta:
ordering = ['site', 'name'] ordering = ['site', 'name']
unique_together = ['site', 'name'] constraints = (
models.UniqueConstraint(
fields=('site', 'name'),
name='%(app_label)s_%(class)s_unique_site_name'
),
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -138,7 +143,12 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
class Meta: class Meta:
ordering = ['power_panel', 'name'] ordering = ['power_panel', 'name']
unique_together = ['power_panel', 'name'] constraints = (
models.UniqueConstraint(
fields=('power_panel', 'name'),
name='%(app_label)s_%(class)s_unique_power_panel_name'
),
)
def __str__(self): def __str__(self):
return self.name return self.name

View File

@ -1,14 +1,14 @@
import decimal import decimal
from functools import cached_property
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Sum from django.db.models import Count
from django.urls import reverse from django.urls import reverse
from dcim.choices import * from dcim.choices import *
@ -18,8 +18,9 @@ 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, drange from utilities.utils import array_to_string, drange
from .device_components import PowerOutlet, PowerPort from .device_components import PowerPort
from .devices import Device from .devices import Device, Module
from .mixins import WeightMixin
from .power import PowerFeed from .power import PowerFeed
__all__ = ( __all__ = (
@ -63,7 +64,7 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk]) return reverse('dcim:rackrole', args=[self.pk])
class Rack(NetBoxModel): class Rack(NetBoxModel, WeightMixin):
""" """
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.
@ -186,15 +187,21 @@ class Rack(NetBoxModel):
clone_fields = ( clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'outer_depth', 'outer_unit', 'weight', 'weight_unit',
) )
class Meta: class Meta:
ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique
unique_together = ( constraints = (
# Name and facility_id must be unique *only* within a Location # Name and facility_id must be unique *only* within a Location
('location', 'name'), models.UniqueConstraint(
('location', 'facility_id'), fields=('location', 'name'),
name='%(app_label)s_%(class)s_unique_location_name'
),
models.UniqueConstraint(
fields=('location', 'facility_id'),
name='%(app_label)s_%(class)s_unique_location_facility_id'
),
) )
def __str__(self): def __str__(self):
@ -449,6 +456,22 @@ class Rack(NetBoxModel):
return int(allocated_draw / available_power_total * 100) return int(allocated_draw / available_power_total * 100)
@cached_property
def total_weight(self):
total_weight = sum(
device.device_type._abs_weight
for device in self.devices.exclude(device_type___abs_weight__isnull=True).prefetch_related('device_type')
)
total_weight += sum(
module.module_type._abs_weight
for module in Module.objects.filter(device__rack=self)
.exclude(module_type___abs_weight__isnull=True)
.prefetch_related('module_type')
)
if self._abs_weight:
total_weight += self._abs_weight
return round(total_weight / 1000, 2)
class RackReservation(NetBoxModel): class RackReservation(NetBoxModel):
""" """

View File

@ -62,38 +62,26 @@ class Region(NestedGroupModel):
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('parent', 'name'), fields=('parent', 'name'),
name='dcim_region_parent_name' name='%(app_label)s_%(class)s_parent_name'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('name',), fields=('name',),
name='dcim_region_name', name='%(app_label)s_%(class)s_name',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A top-level region with this name already exists."
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('parent', 'slug'), fields=('parent', 'slug'),
name='dcim_region_parent_slug' name='%(app_label)s_%(class)s_parent_slug'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('slug',), fields=('slug',),
name='dcim_region_slug', name='%(app_label)s_%(class)s_slug',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A top-level region with this slug already exists."
), ),
) )
def validate_unique(self, exclude=None):
if self.parent is None:
regions = Region.objects.exclude(pk=self.pk)
if regions.filter(name=self.name, parent__isnull=True).exists():
raise ValidationError({
'name': 'A region with this name already exists.'
})
if regions.filter(slug=self.slug, parent__isnull=True).exists():
raise ValidationError({
'name': 'A region with this slug already exists.'
})
super().validate_unique(exclude=exclude)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:region', args=[self.pk]) return reverse('dcim:region', args=[self.pk])
@ -148,38 +136,26 @@ class SiteGroup(NestedGroupModel):
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('parent', 'name'), fields=('parent', 'name'),
name='dcim_sitegroup_parent_name' name='%(app_label)s_%(class)s_parent_name'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('name',), fields=('name',),
name='dcim_sitegroup_name', name='%(app_label)s_%(class)s_name',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A top-level site group with this name already exists."
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('parent', 'slug'), fields=('parent', 'slug'),
name='dcim_sitegroup_parent_slug' name='%(app_label)s_%(class)s_parent_slug'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('slug',), fields=('slug',),
name='dcim_sitegroup_slug', name='%(app_label)s_%(class)s_slug',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A top-level site group with this slug already exists."
), ),
) )
def validate_unique(self, exclude=None):
if self.parent is None:
site_groups = SiteGroup.objects.exclude(pk=self.pk)
if site_groups.filter(name=self.name, parent__isnull=True).exists():
raise ValidationError({
'name': 'A site group with this name already exists.'
})
if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
raise ValidationError({
'name': 'A site group with this slug already exists.'
})
super().validate_unique(exclude=exclude)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:sitegroup', args=[self.pk]) return reverse('dcim:sitegroup', args=[self.pk])
@ -379,38 +355,26 @@ class Location(NestedGroupModel):
constraints = ( constraints = (
models.UniqueConstraint( models.UniqueConstraint(
fields=('site', 'parent', 'name'), fields=('site', 'parent', 'name'),
name='dcim_location_parent_name' name='%(app_label)s_%(class)s_parent_name'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('site', 'name'), fields=('site', 'name'),
name='dcim_location_name', name='%(app_label)s_%(class)s_name',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A location with this name already exists within the specified site."
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('site', 'parent', 'slug'), fields=('site', 'parent', 'slug'),
name='dcim_location_parent_slug' name='%(app_label)s_%(class)s_parent_slug'
), ),
models.UniqueConstraint( models.UniqueConstraint(
fields=('site', 'slug'), fields=('site', 'slug'),
name='dcim_location_slug', name='%(app_label)s_%(class)s_slug',
condition=Q(parent=None) condition=Q(parent__isnull=True),
violation_error_message="A location with this slug already exists within the specified site."
), ),
) )
def validate_unique(self, exclude=None):
if self.parent is None:
locations = Location.objects.exclude(pk=self.pk)
if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
raise ValidationError({
"name": f"A location with this name in site {self.site} already exists."
})
if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
raise ValidationError({
"name": f"A location with this slug in site {self.site} already exists."
})
super().validate_unique(exclude=exclude)
@classmethod @classmethod
def get_prerequisite_models(cls): def get_prerequisite_models(cls):
return [Site, ] return [Site, ]

View File

@ -35,7 +35,7 @@ class Node(Hyperlink):
""" """
def __init__(self, position, width, url, color, labels, radius=10, **extra): def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra) super(Node, self).__init__(href=url, target='_parent', **extra)
x, y = position x, y = position

View File

@ -9,6 +9,7 @@ from svgwrite.text import Text
from django.conf import settings from django.conf import settings
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.db.models import Q from django.db.models import Q
from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.http import urlencode from django.utils.http import urlencode
@ -41,7 +42,7 @@ def get_device_description(device):
device.device_role, device.device_role,
device.device_type.manufacturer.name, device.device_type.manufacturer.name,
device.device_type.model, device.device_type.model,
device.device_type.u_height, floatformat(device.device_type.u_height),
device.asset_tag or '', device.asset_tag or '',
device.serial or '' device.serial or ''
) )

View File

@ -5,7 +5,7 @@ from dcim.models import (
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
__all__ = ( __all__ = (
'ConsolePortTemplateTable', 'ConsolePortTemplateTable',
@ -85,12 +85,22 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:devicetype_list' url_name='dcim:devicetype_list'
) )
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
class Meta(NetBoxTable.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', 'created', 'last_updated', 'airflow', 'weight', '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',

View File

@ -2,6 +2,7 @@ import django_tables2 as tables
from dcim.models import Module, ModuleType from dcim.models import Module, ModuleType
from netbox.tables import NetBoxTable, columns from netbox.tables import NetBoxTable, columns
from .template_code import DEVICE_WEIGHT
__all__ = ( __all__ = (
'ModuleTable', 'ModuleTable',
@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:moduletype_list' url_name='dcim:moduletype_list'
) )
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.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', 'weight', 'comments', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'pk', 'model', 'manufacturer', 'part_number',

View File

@ -4,6 +4,7 @@ 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 netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin from tenancy.tables import TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT
__all__ = ( __all__ = (
'RackTable', 'RackTable',
@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
template_code="{{ record.outer_depth }} {{ record.outer_unit }}", template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth' verbose_name='Outer Depth'
) )
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments',
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated', 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', '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',

View File

@ -15,6 +15,11 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
""" """
DEVICE_WEIGHT = """
{% load helpers %}
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """ DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}"> <a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }} {{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}

View File

@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
racks = ( racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
Rack.objects.bulk_create(racks) Rack.objects.bulk_create(racks)
@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RackReservation.objects.all() queryset = RackReservation.objects.all()
@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
device_types = ( device_types = (
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
DeviceType.objects.bulk_create(device_types) DeviceType.objects.bulk_create(device_types)
@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'inventory_items': 'false'} params = {'inventory_items': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests): class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
module_types = ( module_types = (
ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'), ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1', weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'), ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'), ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3', weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
) )
ModuleType.objects.bulk_create(module_types) ModuleType.objects.bulk_create(module_types)
@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'pass_through_ports': 'false'} params = {'pass_through_ports': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ConsolePortTemplate.objects.all() queryset = ConsolePortTemplate.objects.all()
@ -1611,6 +1635,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_name(self): def test_name(self):
params = {'name': ['Device 1', 'Device 2']} params = {'name': ['Device 1', 'Device 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# Test case insensitivity
params = {'name': ['DEVICE 1', 'DEVICE 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asset_tag(self): def test_asset_tag(self):
params = {'asset_tag': ['1001', '1002']} params = {'asset_tag': ['1001', '1002']}

View File

@ -384,7 +384,7 @@ class DeviceTestCase(TestCase):
site=self.site, site=self.site,
device_type=self.device_type, device_type=self.device_type,
device_role=self.device_role, device_role=self.device_role,
name='' name=None
) )
device1.save() device1.save()
@ -392,12 +392,33 @@ class DeviceTestCase(TestCase):
site=device1.site, site=device1.site,
device_type=device1.device_type, device_type=device1.device_type,
device_role=device1.device_role, device_role=device1.device_role,
name='' name=None
) )
device2.full_clean() device2.full_clean()
device2.save() device2.save()
self.assertEqual(Device.objects.filter(name='').count(), 2) self.assertEqual(Device.objects.filter(name__isnull=True).count(), 2)
def test_device_name_case_sensitivity(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name='device 1'
)
device1.save()
device2 = Device(
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
name='DEVICE 1'
)
# Uniqueness validation for name should ignore case
with self.assertRaises(ValidationError):
device2.full_clean()
def test_device_duplicate_names(self): def test_device_duplicate_names(self):

View File

@ -1778,10 +1778,12 @@ class ModuleTestCase(
ModuleBay(device=devices[0], name='Module Bay 2'), ModuleBay(device=devices[0], name='Module Bay 2'),
ModuleBay(device=devices[0], name='Module Bay 3'), ModuleBay(device=devices[0], name='Module Bay 3'),
ModuleBay(device=devices[0], name='Module Bay 4'), ModuleBay(device=devices[0], name='Module Bay 4'),
ModuleBay(device=devices[0], name='Module Bay 5'),
ModuleBay(device=devices[1], name='Module Bay 1'), ModuleBay(device=devices[1], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[1], name='Module Bay 3'), ModuleBay(device=devices[1], name='Module Bay 3'),
ModuleBay(device=devices[1], name='Module Bay 4'), ModuleBay(device=devices[1], name='Module Bay 4'),
ModuleBay(device=devices[1], name='Module Bay 5'),
) )
ModuleBay.objects.bulk_create(module_bays) ModuleBay.objects.bulk_create(module_bays)
@ -1795,7 +1797,7 @@ class ModuleTestCase(
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': devices[1].pk, 'device': devices[0].pk,
'module_bay': module_bays[3].pk, 'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk, 'module_type': module_types[0].pk,
'serial': 'A', 'serial': 'A',
@ -1867,7 +1869,6 @@ class ModuleTestCase(
self.assertIsNone(interface.module) self.assertIsNone(interface.module)
# Create a module with adopted components # Create a module with adopted components
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
form_data['module_type'] = module_type form_data['module_type'] = module_type
form_data['replicate_components'] = False form_data['replicate_components'] = False
form_data['adopt_components'] = True form_data['adopt_components'] = True

View File

@ -1,8 +1,7 @@
from django.urls import path from django.urls import include, path
from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from utilities.urls import get_model_urls
from . import views from . import views
from .models import *
app_name = 'dcim' app_name = 'dcim'
urlpatterns = [ urlpatterns = [
@ -13,10 +12,7 @@ urlpatterns = [
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/', views.RegionView.as_view(), name='region'), path('regions/<int:pk>/', include(get_model_urls('dcim', 'region'))),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Site groups # Site groups
path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'),
@ -24,10 +20,7 @@ urlpatterns = [
path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'),
path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
path('site-groups/<int:pk>/', views.SiteGroupView.as_view(), name='sitegroup'), path('site-groups/<int:pk>/', include(get_model_urls('dcim', 'sitegroup'))),
path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
path('site-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
# Sites # Sites
path('sites/', views.SiteListView.as_view(), name='site_list'), path('sites/', views.SiteListView.as_view(), name='site_list'),
@ -35,11 +28,7 @@ urlpatterns = [
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path('sites/<int:pk>/', views.SiteView.as_view(), name='site'), path('sites/<int:pk>/', include(get_model_urls('dcim', 'site'))),
path('sites/<int:pk>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
# Locations # Locations
path('locations/', views.LocationListView.as_view(), name='location_list'), path('locations/', views.LocationListView.as_view(), name='location_list'),
@ -47,10 +36,7 @@ urlpatterns = [
path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
path('locations/<int:pk>/', views.LocationView.as_view(), name='location'), path('locations/<int:pk>/', include(get_model_urls('dcim', 'location'))),
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
# Rack roles # Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
@ -58,10 +44,7 @@ urlpatterns = [
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles/<int:pk>/', include(get_model_urls('dcim', 'rackrole'))),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations # Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
@ -69,11 +52,7 @@ urlpatterns = [
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'), path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path('rack-reservations/<int:pk>/', views.RackReservationView.as_view(), name='rackreservation'), path('rack-reservations/<int:pk>/', include(get_model_urls('dcim', 'rackreservation'))),
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path('rack-reservations/<int:pk>/journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
# Racks # Racks
path('racks/', views.RackListView.as_view(), name='rack_list'), path('racks/', views.RackListView.as_view(), name='rack_list'),
@ -82,11 +61,7 @@ urlpatterns = [
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', views.RackView.as_view(), name='rack'), path('racks/<int:pk>/', include(get_model_urls('dcim', 'rack'))),
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
# Manufacturers # Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
@ -94,10 +69,7 @@ urlpatterns = [
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<int:pk>/', views.ManufacturerView.as_view(), name='manufacturer'), path('manufacturers/<int:pk>/', include(get_model_urls('dcim', 'manufacturer'))),
path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
path('manufacturers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types # Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
@ -105,21 +77,7 @@ urlpatterns = [
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'), path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'), path('device-types/<int:pk>/', include(get_model_urls('dcim', 'devicetype'))),
path('device-types/<int:pk>/console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'),
path('device-types/<int:pk>/console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'),
path('device-types/<int:pk>/power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'),
path('device-types/<int:pk>/power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'),
path('device-types/<int:pk>/interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
path('device-types/<int:pk>/front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
path('device-types/<int:pk>/inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'),
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
# Module types # Module types
path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'), path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'),
@ -127,98 +85,77 @@ urlpatterns = [
path('module-types/import/', views.ModuleTypeImportView.as_view(), name='moduletype_import'), path('module-types/import/', views.ModuleTypeImportView.as_view(), name='moduletype_import'),
path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'), path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'),
path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'), path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'),
path('module-types/<int:pk>/', views.ModuleTypeView.as_view(), name='moduletype'), path('module-types/<int:pk>/', include(get_model_urls('dcim', 'moduletype'))),
path('module-types/<int:pk>/console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'),
path('module-types/<int:pk>/console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'),
path('module-types/<int:pk>/power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'),
path('module-types/<int:pk>/power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'),
path('module-types/<int:pk>/interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'),
path('module-types/<int:pk>/front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'),
path('module-types/<int:pk>/rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'),
path('module-types/<int:pk>/edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'),
path('module-types/<int:pk>/delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
path('module-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}),
path('module-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}),
# Console port templates # Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'), path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'),
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), path('console-port-templates/<int:pk>/', include(get_model_urls('dcim', 'consoleporttemplate'))),
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
# Console server port templates # Console server port templates
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'), path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'),
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), path('console-server-port-templates/<int:pk>/', include(get_model_urls('dcim', 'consoleserverporttemplate'))),
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
# Power port templates # Power port templates
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'), path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'),
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), path('power-port-templates/<int:pk>/', include(get_model_urls('dcim', 'powerporttemplate'))),
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
# Power outlet templates # Power outlet templates
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'), path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'),
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), path('power-outlet-templates/<int:pk>/', include(get_model_urls('dcim', 'poweroutlettemplate'))),
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
# Interface templates # Interface templates
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'), path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), path('interface-templates/<int:pk>/', include(get_model_urls('dcim', 'interfacetemplate'))),
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
# Front port templates # Front port templates
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'), path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'),
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), path('front-port-templates/<int:pk>/', include(get_model_urls('dcim', 'frontporttemplate'))),
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
# Rear port templates # Rear port templates
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'), path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'),
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), path('rear-port-templates/<int:pk>/', include(get_model_urls('dcim', 'rearporttemplate'))),
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates # Device bay templates
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'), path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates/<int:pk>/', include(get_model_urls('dcim', 'devicebaytemplate'))),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Module bay templates # Module bay templates
path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'),
path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'),
path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'),
path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'), path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'),
path('module-bay-templates/<int:pk>/edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), path('module-bay-templates/<int:pk>/', include(get_model_urls('dcim', 'modulebaytemplate'))),
path('module-bay-templates/<int:pk>/delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'),
# Inventory item templates # Inventory item templates
path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'), path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'),
path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'), path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'),
path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'), path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'),
path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'), path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'),
path('inventory-item-templates/<int:pk>/edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'), path('inventory-item-templates/<int:pk>/', include(get_model_urls('dcim', 'inventoryitemtemplate'))),
path('inventory-item-templates/<int:pk>/delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'),
# Device roles # Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
@ -226,10 +163,7 @@ urlpatterns = [
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<int:pk>/', views.DeviceRoleView.as_view(), name='devicerole'), path('device-roles/<int:pk>/', include(get_model_urls('dcim', 'devicerole'))),
path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
path('device-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms # Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'), path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
@ -237,10 +171,7 @@ urlpatterns = [
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<int:pk>/', views.PlatformView.as_view(), name='platform'), path('platforms/<int:pk>/', include(get_model_urls('dcim', 'platform'))),
path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
path('platforms/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices # Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'), path('devices/', views.DeviceListView.as_view(), name='device_list'),
@ -250,25 +181,7 @@ urlpatterns = [
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'), path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'), path('devices/<int:pk>/', include(get_model_urls('dcim', 'device'))),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
path('devices/<int:pk>/console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'),
path('devices/<int:pk>/power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'),
path('devices/<int:pk>/power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'),
path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
path('devices/<int:pk>/module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'),
path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
# Modules # Modules
path('modules/', views.ModuleListView.as_view(), name='module_list'), path('modules/', views.ModuleListView.as_view(), name='module_list'),
@ -276,11 +189,7 @@ urlpatterns = [
path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'), path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'),
path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'), path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'),
path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'), path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'),
path('modules/<int:pk>/', views.ModuleView.as_view(), name='module'), path('modules/<int:pk>/', include(get_model_urls('dcim', 'module'))),
path('modules/<int:pk>/edit/', views.ModuleEditView.as_view(), name='module_edit'),
path('modules/<int:pk>/delete/', views.ModuleDeleteView.as_view(), name='module_delete'),
path('modules/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}),
path('modules/<int:pk>/journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}),
# Console ports # Console ports
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
@ -290,11 +199,7 @@ urlpatterns = [
path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'), path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'),
path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'), path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path('console-ports/<int:pk>/', views.ConsolePortView.as_view(), name='consoleport'), path('console-ports/<int:pk>/', include(get_model_urls('dcim', 'consoleport'))),
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports # Console server ports
@ -305,11 +210,7 @@ urlpatterns = [
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path('console-server-ports/<int:pk>/', views.ConsoleServerPortView.as_view(), name='consoleserverport'), path('console-server-ports/<int:pk>/', include(get_model_urls('dcim', 'consoleserverport'))),
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports # Power ports
@ -320,11 +221,7 @@ urlpatterns = [
path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'), path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'),
path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'), path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'),
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path('power-ports/<int:pk>/', views.PowerPortView.as_view(), name='powerport'), path('power-ports/<int:pk>/', include(get_model_urls('dcim', 'powerport'))),
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets # Power outlets
@ -335,11 +232,7 @@ urlpatterns = [
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path('power-outlets/<int:pk>/', views.PowerOutletView.as_view(), name='poweroutlet'), path('power-outlets/<int:pk>/', include(get_model_urls('dcim', 'poweroutlet'))),
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces # Interfaces
@ -350,11 +243,7 @@ urlpatterns = [
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'), path('interfaces/<int:pk>/', include(get_model_urls('dcim', 'interface'))),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports # Front ports
@ -365,11 +254,7 @@ urlpatterns = [
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path('front-ports/<int:pk>/', views.FrontPortView.as_view(), name='frontport'), path('front-ports/<int:pk>/', include(get_model_urls('dcim', 'frontport'))),
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports # Rear ports
@ -380,11 +265,7 @@ urlpatterns = [
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path('rear-ports/<int:pk>/', views.RearPortView.as_view(), name='rearport'), path('rear-ports/<int:pk>/', include(get_model_urls('dcim', 'rearport'))),
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Module bays # Module bays
@ -394,10 +275,7 @@ urlpatterns = [
path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'), path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'),
path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'), path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'),
path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'), path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'),
path('module-bays/<int:pk>/', views.ModuleBayView.as_view(), name='modulebay'), path('module-bays/<int:pk>/', include(get_model_urls('dcim', 'modulebay'))),
path('module-bays/<int:pk>/edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'),
path('module-bays/<int:pk>/delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'),
path('module-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}),
path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'), path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'),
# Device bays # Device bays
@ -407,12 +285,7 @@ urlpatterns = [
path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'), path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/', views.DeviceBayView.as_view(), name='devicebay'), path('device-bays/<int:pk>/', include(get_model_urls('dcim', 'devicebay'))),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path('device-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
# Inventory items # Inventory items
@ -422,22 +295,16 @@ urlpatterns = [
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'), path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'),
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path('inventory-items/<int:pk>/', views.InventoryItemView.as_view(), name='inventoryitem'), path('inventory-items/<int:pk>/', include(get_model_urls('dcim', 'inventoryitem'))),
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'), path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
# Device roles # Inventory item roles
path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'), path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'),
path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'), path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'),
path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'), path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'),
path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'), path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'),
path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'), path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'),
path('inventory-item-roles/<int:pk>/', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'), path('inventory-item-roles/<int:pk>/', include(get_model_urls('dcim', 'inventoryitemrole'))),
path('inventory-item-roles/<int:pk>/edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'),
path('inventory-item-roles/<int:pk>/delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'),
path('inventory-item-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}),
# Cables # Cables
path('cables/', views.CableListView.as_view(), name='cable_list'), path('cables/', views.CableListView.as_view(), name='cable_list'),
@ -445,11 +312,7 @@ urlpatterns = [
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path('cables/<int:pk>/', views.CableView.as_view(), name='cable'), path('cables/<int:pk>/', include(get_model_urls('dcim', 'cable'))),
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path('cables/<int:pk>/journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
# Console/power/interface connections (read-only) # Console/power/interface connections (read-only)
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
@ -462,12 +325,7 @@ urlpatterns = [
path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'), path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'), path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'), path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'), path('virtual-chassis/<int:pk>/', include(get_model_urls('dcim', 'virtualchassis'))),
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels # Power panels
@ -476,11 +334,7 @@ urlpatterns = [
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'), path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path('power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'), path('power-panels/<int:pk>/', include(get_model_urls('dcim', 'powerpanel'))),
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
path('power-panels/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
# Power feeds # Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
@ -489,11 +343,6 @@ urlpatterns = [
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'), path('power-feeds/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'), path('power-feeds/<int:pk>/', include(get_model_urls('dcim', 'powerfeed'))),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/<int:pk>/journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}),
] ]

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
types = CustomFieldTypeChoices types = CustomFieldTypeChoices
if obj.type == types.TYPE_INTEGER: if obj.type == types.TYPE_INTEGER:
return 'integer' return 'integer'
if obj.type == types.TYPE_DECIMAL:
return 'decimal'
if obj.type == types.TYPE_BOOLEAN: if obj.type == types.TYPE_BOOLEAN:
return 'boolean' return 'boolean'
if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):

View File

@ -10,6 +10,7 @@ class CustomFieldTypeChoices(ChoiceSet):
TYPE_TEXT = 'text' TYPE_TEXT = 'text'
TYPE_LONGTEXT = 'longtext' TYPE_LONGTEXT = 'longtext'
TYPE_INTEGER = 'integer' TYPE_INTEGER = 'integer'
TYPE_DECIMAL = 'decimal'
TYPE_BOOLEAN = 'boolean' TYPE_BOOLEAN = 'boolean'
TYPE_DATE = 'date' TYPE_DATE = 'date'
TYPE_URL = 'url' TYPE_URL = 'url'
@ -23,6 +24,7 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_TEXT, 'Text'), (TYPE_TEXT, 'Text'),
(TYPE_LONGTEXT, 'Text (long)'), (TYPE_LONGTEXT, 'Text (long)'),
(TYPE_INTEGER, 'Integer'), (TYPE_INTEGER, 'Integer'),
(TYPE_DECIMAL, 'Decimal'),
(TYPE_BOOLEAN, 'Boolean (true/false)'), (TYPE_BOOLEAN, 'Boolean (true/false)'),
(TYPE_DATE, 'Date'), (TYPE_DATE, 'Date'),
(TYPE_URL, 'URL'), (TYPE_URL, 'URL'),

View File

@ -34,7 +34,9 @@ class CustomFieldsMixin:
return ContentType.objects.get_for_model(self.model) return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type) return CustomField.objects.filter(content_types=content_type).exclude(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
)
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
return customfield.to_form_field() return customfield.to_form_field()
@ -50,13 +52,6 @@ class CustomFieldsMixin:
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)
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
self.fields[field_name].disabled = True
if self.fields[field_name].help_text:
self.fields[field_name].help_text += '<br />'
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
'Field is set to read-only.'
# Annotate the field in the list of CustomField form fields # Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield self.custom_fields[field_name] = customfield
if customfield.group_name not in self.custom_field_groups: if customfield.group_name not in self.custom_field_groups:

View File

@ -1,4 +1,4 @@
import django.core.serializers.json from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models from django.db import migrations, models
import taggit.managers import taggit.managers
@ -13,7 +13,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='journalentry', model_name='journalentry',
name='custom_field_data', name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), field=models.JSONField(blank=True, default=dict, encoder=CustomFieldJSONEncoder),
), ),
migrations.AddField( migrations.AddField(
model_name='journalentry', model_name='journalentry',

View File

@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0077_customlink_extend_text_and_url'),
]
operations = [
migrations.AlterUniqueTogether(
name='exporttemplate',
unique_together=set(),
),
migrations.AlterUniqueTogether(
name='webhook',
unique_together=set(),
),
migrations.AddConstraint(
model_name='exporttemplate',
constraint=models.UniqueConstraint(fields=('content_type', 'name'), name='extras_exporttemplate_unique_content_type_name'),
),
migrations.AddConstraint(
model_name='webhook',
constraint=models.UniqueConstraint(fields=('payload_url', 'type_create', 'type_update', 'type_delete'), name='extras_webhook_unique_payload_url_types'),
),
]

View File

@ -1,5 +1,6 @@
import re import re
from datetime import datetime, date from datetime import datetime, date
import decimal
import django_filters import django_filters
from django import forms from django import forms
@ -219,14 +220,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
}) })
# Minimum/maximum values can be set only for numeric fields # Minimum/maximum values can be set only for numeric fields
if self.validation_minimum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: if self.type not in (CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_DECIMAL):
raise ValidationError({ if self.validation_minimum:
'validation_minimum': "A minimum value may be set only for numeric fields" raise ValidationError({'validation_minimum': "A minimum value may be set only for numeric fields"})
}) if self.validation_maximum:
if self.validation_maximum is not None and self.type != CustomFieldTypeChoices.TYPE_INTEGER: raise ValidationError({'validation_maximum': "A maximum value may be set only for numeric fields"})
raise ValidationError({
'validation_maximum': "A maximum value may be set only for numeric fields"
})
# Regex validation can be set only for text fields # Regex validation can be set only for text fields
regex_types = ( regex_types = (
@ -297,12 +295,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
return model.objects.filter(pk__in=value) return model.objects.filter(pk__in=value)
return 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, enforce_visibility=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 data 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.
enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
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.
""" """
initial = self.default if set_initial else None initial = self.default if set_initial else None
@ -317,6 +316,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
max_value=self.validation_maximum max_value=self.validation_maximum
) )
# Decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
field = forms.DecimalField(
required=required,
initial=initial,
max_digits=12,
decimal_places=4,
min_value=self.validation_minimum,
max_value=self.validation_maximum
)
# Boolean # Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = ( choices = (
@ -398,6 +408,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.description: if self.description:
field.help_text = escape(self.description) field.help_text = escape(self.description)
# Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
field.disabled = True
prepend = '<br />' if field.help_text else ''
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
return field return field
def to_filter(self, lookup_expr=None): def to_filter(self, lookup_expr=None):
@ -426,6 +442,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER: elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
filter_class = filters.MultiValueNumberFilter filter_class = filters.MultiValueNumberFilter
# Decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
filter_class = filters.MultiValueDecimalFilter
# Boolean # Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
filter_class = django_filters.BooleanFilter filter_class = django_filters.BooleanFilter
@ -475,7 +495,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
raise ValidationError(f"Value must match regex '{self.validation_regex}'") raise ValidationError(f"Value must match regex '{self.validation_regex}'")
# Validate integer # Validate integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER: elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
if type(value) is not int: if type(value) is not int:
raise ValidationError("Value must be an integer.") raise ValidationError("Value must be an integer.")
if self.validation_minimum is not None and value < self.validation_minimum: if self.validation_minimum is not None and value < self.validation_minimum:
@ -483,12 +503,23 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
if self.validation_maximum is not None and value > self.validation_maximum: if self.validation_maximum is not None and value > self.validation_maximum:
raise ValidationError(f"Value must not exceed {self.validation_maximum}") raise ValidationError(f"Value must not exceed {self.validation_maximum}")
# Validate decimal
elif self.type == CustomFieldTypeChoices.TYPE_DECIMAL:
try:
decimal.Decimal(value)
except decimal.InvalidOperation:
raise ValidationError("Value must be a decimal.")
if self.validation_minimum is not None and value < self.validation_minimum:
raise ValidationError(f"Value must be at least {self.validation_minimum}")
if self.validation_maximum is not None and value > self.validation_maximum:
raise ValidationError(f"Value must not exceed {self.validation_maximum}")
# Validate boolean # Validate boolean
if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]: elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError("Value must be true or false.") raise ValidationError("Value must be true or false.")
# Validate date # Validate date
if self.type == CustomFieldTypeChoices.TYPE_DATE: elif self.type == CustomFieldTypeChoices.TYPE_DATE:
if type(value) is not date: if type(value) is not date:
try: try:
datetime.strptime(value, '%Y-%m-%d') datetime.strptime(value, '%Y-%m-%d')
@ -496,14 +527,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
raise ValidationError("Date values must be in the format YYYY-MM-DD.") raise ValidationError("Date values must be in the format YYYY-MM-DD.")
# Validate selected choice # Validate selected choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT: elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
if value not in self.choices: if value not in self.choices:
raise ValidationError( raise ValidationError(
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
) )
# Validate all selected choices # Validate all selected choices
if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
if not set(value).issubset(self.choices): if not set(value).issubset(self.choices):
raise ValidationError( raise ValidationError(
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"

View File

@ -131,7 +131,12 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) constraints = (
models.UniqueConstraint(
fields=('payload_url', 'type_create', 'type_update', 'type_delete'),
name='%(app_label)s_%(class)s_unique_payload_url_types'
),
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -297,9 +302,12 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class Meta: class Meta:
ordering = ['content_type', 'name'] ordering = ['content_type', 'name']
unique_together = [ constraints = (
['content_type', 'name'] models.UniqueConstraint(
] fields=('content_type', 'name'),
name='%(app_label)s_%(class)s_unique_content_type_name'
),
)
def __str__(self): def __str__(self):
return f"{self.content_type}: {self.name}" return f"{self.content_type}: {self.name}"
@ -463,6 +471,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk]) return reverse('extras:journalentry', args=[self.pk])
def clean(self):
super().clean()
# Prevent the creation of journal entries on unsupported models
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
if self.assigned_object_type not in permitted_types:
raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
def get_kind_color(self): def get_kind_color(self):
return JournalEntryKindChoices.colors.get(self.kind) return JournalEntryKindChoices.colors.get(self.kind)

View File

@ -6,15 +6,16 @@ from django.apps import AppConfig
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.template.loader import get_template from django.template.loader import get_template
from extras.registry import registry
from utilities.choices import ButtonColorChoices
from extras.plugins.utils import import_object from extras.plugins.utils import import_object
from extras.registry import registry
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
# Initialize plugin registry # Initialize plugin registry
registry['plugins'] = { registry['plugins'] = {
'graphql_schemas': [], 'graphql_schemas': [],
'menus': [],
'menu_items': {}, 'menu_items': {},
'preferences': {}, 'preferences': {},
'template_extensions': collections.defaultdict(list), 'template_extensions': collections.defaultdict(list),
@ -54,9 +55,13 @@ class PluginConfig(AppConfig):
# Django-rq queues dedicated to the plugin # Django-rq queues dedicated to the plugin
queues = [] queues = []
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Default integration paths. Plugin authors can override these to customize the paths to # Default integration paths. Plugin authors can override these to customize the paths to
# integrated components. # integrated components.
graphql_schema = 'graphql.schema' graphql_schema = 'graphql.schema'
menu = 'navigation.menu'
menu_items = 'navigation.menu_items' menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions' template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences' user_preferences = 'preferences.preferences'
@ -69,9 +74,10 @@ class PluginConfig(AppConfig):
if template_extensions is not None: if template_extensions is not None:
register_template_extensions(template_extensions) register_template_extensions(template_extensions)
# Register navigation menu items (if defined) # Register navigation menu or menu items (if defined)
menu_items = import_object(f"{self.__module__}.{self.menu_items}") if menu := import_object(f"{self.__module__}.{self.menu}"):
if menu_items is not None: register_menu(menu)
if menu_items := import_object(f"{self.__module__}.{self.menu_items}"):
register_menu_items(self.verbose_name, menu_items) register_menu_items(self.verbose_name, menu_items)
# Register GraphQL schema (if defined) # Register GraphQL schema (if defined)
@ -200,6 +206,18 @@ def register_template_extensions(class_list):
# Navigation menu links # Navigation menu links
# #
class PluginMenu:
icon_class = 'mdi mdi-puzzle'
def __init__(self, label, groups, icon_class=None):
self.label = label
self.groups = [
MenuGroup(label, items) for label, items in groups
]
if icon_class is not None:
self.icon_class = icon_class
class PluginMenuItem: class PluginMenuItem:
""" """
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
@ -246,6 +264,12 @@ class PluginMenuButton:
self.color = color self.color = color
def register_menu(menu):
if not isinstance(menu, PluginMenu):
raise TypeError(f"{menu} must be an instance of extras.plugins.PluginMenu")
registry['plugins']['menus'].append(menu)
def register_menu_items(section_name, class_list): def register_menu_items(section_name, class_list):
""" """
Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name) Register a list of PluginMenuItem instances for a given menu section (e.g. plugin name)

View File

@ -29,3 +29,4 @@ registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
} }
registry['denormalized_fields'] = collections.defaultdict(list) registry['denormalized_fields'] = collections.defaultdict(list)
registry['views'] = collections.defaultdict(dict)

View File

@ -1,7 +1,7 @@
from extras.plugins import PluginMenuButton, PluginMenuItem from extras.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
menu_items = ( items = (
PluginMenuItem( PluginMenuItem(
link='plugins:dummy_plugin:dummy_models', link='plugins:dummy_plugin:dummy_models',
link_text='Item 1', link_text='Item 1',
@ -23,3 +23,9 @@ menu_items = (
link_text='Item 2', link_text='Item 2',
), ),
) )
menu = PluginMenu(
label='Dummy',
groups=(('Group 1', items),),
)
menu_items = items

View File

@ -1,6 +1,8 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.views.generic import View from django.views.generic import View
from dcim.models import Site
from utilities.views import register_model_view
from .models import DummyModel from .models import DummyModel
@ -9,3 +11,10 @@ class DummyModelsView(View):
def get(self, request): def get(self, request):
instance_count = DummyModel.objects.count() instance_count = DummyModel.objects.count()
return HttpResponse(f"Instances: {instance_count}") return HttpResponse(f"Instances: {instance_count}")
@register_model_view(Site, 'extra', path='other-stuff')
class ExtraCoreModelView(View):
def get(self, request, pk):
return HttpResponse("Success!")

View File

@ -1,3 +1,5 @@
from decimal import Decimal
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.urls import reverse from django.urls import reverse
@ -102,6 +104,32 @@ class CustomFieldTest(TestCase):
instance.refresh_from_db() instance.refresh_from_db()
self.assertIsNone(instance.custom_field_data.get(cf.name)) self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_decimal_field(self):
# Create a custom field & check that initial value is null
cf = CustomField.objects.create(
name='decimal_field',
type=CustomFieldTypeChoices.TYPE_DECIMAL,
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.54, 0, -123456.78):
# 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): def test_boolean_field(self):
# Create a custom field & check that initial value is null # Create a custom field & check that initial value is null
@ -373,7 +401,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields = ( custom_fields = (
CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'),
CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'),
CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='integer_field', default=123),
CustomField(type=CustomFieldTypeChoices.TYPE_DECIMAL, name='decimal_field', default=123.45),
CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), 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_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_URL, name='url_field', default='http://example.com/1'),
@ -424,14 +453,15 @@ class CustomFieldAPITest(APITestCase):
custom_fields[0].name: 'bar', custom_fields[0].name: 'bar',
custom_fields[1].name: 'DEF', custom_fields[1].name: 'DEF',
custom_fields[2].name: 456, custom_fields[2].name: 456,
custom_fields[3].name: True, custom_fields[3].name: Decimal('456.78'),
custom_fields[4].name: '2020-01-02', custom_fields[4].name: True,
custom_fields[5].name: 'http://example.com/2', custom_fields[5].name: '2020-01-02',
custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[6].name: 'http://example.com/2',
custom_fields[7].name: 'Bar', custom_fields[7].name: '{"foo": 1, "bar": 2}',
custom_fields[8].name: ['Bar', 'Baz'], custom_fields[8].name: 'Bar',
custom_fields[9].name: vlans[1].pk, custom_fields[9].name: ['Bar', 'Baz'],
custom_fields[10].name: [vlans[2].pk, vlans[3].pk], custom_fields[10].name: vlans[1].pk,
custom_fields[11].name: [vlans[2].pk, vlans[3].pk],
} }
sites[1].save() sites[1].save()
@ -440,6 +470,7 @@ class CustomFieldAPITest(APITestCase):
CustomFieldTypeChoices.TYPE_TEXT: 'string', CustomFieldTypeChoices.TYPE_TEXT: 'string',
CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
CustomFieldTypeChoices.TYPE_INTEGER: 'integer', CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
CustomFieldTypeChoices.TYPE_DECIMAL: 'decimal',
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
CustomFieldTypeChoices.TYPE_DATE: 'string', CustomFieldTypeChoices.TYPE_DATE: 'string',
CustomFieldTypeChoices.TYPE_URL: 'string', CustomFieldTypeChoices.TYPE_URL: 'string',
@ -473,7 +504,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields'], { self.assertEqual(response.data['custom_fields'], {
'text_field': None, 'text_field': None,
'longtext_field': None, 'longtext_field': None,
'number_field': None, 'integer_field': None,
'decimal_field': None,
'boolean_field': None, 'boolean_field': None,
'date_field': None, 'date_field': None,
'url_field': None, 'url_field': None,
@ -497,7 +529,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['name'], site2.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']['integer_field'], site2_cfvs['integer_field'])
self.assertEqual(response.data['custom_fields']['decimal_field'], site2_cfvs['decimal_field'])
self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field']) self.assertEqual(response.data['custom_fields']['boolean_field'], site2_cfvs['boolean_field'])
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'])
@ -531,7 +564,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data['custom_fields'] response_cf = response.data['custom_fields']
self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@ -548,7 +582,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -568,7 +603,8 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': { 'custom_fields': {
'text_field': 'bar', 'text_field': 'bar',
'longtext_field': 'blah blah blah', 'longtext_field': 'blah blah blah',
'number_field': 456, 'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
@ -590,7 +626,8 @@ class CustomFieldAPITest(APITestCase):
data_cf = data['custom_fields'] data_cf = data['custom_fields']
self.assertEqual(response_cf['text_field'], data_cf['text_field']) self.assertEqual(response_cf['text_field'], data_cf['text_field'])
self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field']) self.assertEqual(response_cf['longtext_field'], data_cf['longtext_field'])
self.assertEqual(response_cf['number_field'], data_cf['number_field']) self.assertEqual(response_cf['integer_field'], data_cf['integer_field'])
self.assertEqual(response_cf['decimal_field'], data_cf['decimal_field'])
self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field']) self.assertEqual(response_cf['boolean_field'], data_cf['boolean_field'])
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'])
@ -607,7 +644,8 @@ class CustomFieldAPITest(APITestCase):
site = Site.objects.get(pk=response.data['id']) site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field']) self.assertEqual(site.custom_field_data['text_field'], data_cf['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], data_cf['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], data_cf['number_field']) self.assertEqual(site.custom_field_data['integer_field'], data_cf['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], data_cf['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], data_cf['boolean_field'])
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'])
@ -652,7 +690,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) self.assertEqual(response_cf['text_field'], cf_defaults['text_field'])
self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) self.assertEqual(response_cf['integer_field'], cf_defaults['integer_field'])
self.assertEqual(response_cf['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
@ -669,7 +708,8 @@ class CustomFieldAPITest(APITestCase):
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'], cf_defaults['text_field']) self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) self.assertEqual(site.custom_field_data['integer_field'], cf_defaults['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], cf_defaults['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field'])
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field'])
self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field'])
@ -686,7 +726,8 @@ class CustomFieldAPITest(APITestCase):
custom_field_data = { custom_field_data = {
'text_field': 'bar', 'text_field': 'bar',
'longtext_field': 'abcdefghij', 'longtext_field': 'abcdefghij',
'number_field': 456, 'integer_field': 456,
'decimal_field': 456.78,
'boolean_field': True, 'boolean_field': True,
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
@ -726,7 +767,8 @@ class CustomFieldAPITest(APITestCase):
response_cf = response.data[i]['custom_fields'] response_cf = response.data[i]['custom_fields']
self.assertEqual(response_cf['text_field'], custom_field_data['text_field']) self.assertEqual(response_cf['text_field'], custom_field_data['text_field'])
self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(response_cf['longtext_field'], custom_field_data['longtext_field'])
self.assertEqual(response_cf['number_field'], custom_field_data['number_field']) self.assertEqual(response_cf['integer_field'], custom_field_data['integer_field'])
self.assertEqual(response_cf['decimal_field'], custom_field_data['decimal_field'])
self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(response_cf['boolean_field'], custom_field_data['boolean_field'])
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'])
@ -743,7 +785,8 @@ class CustomFieldAPITest(APITestCase):
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'], custom_field_data['text_field']) self.assertEqual(site.custom_field_data['text_field'], custom_field_data['text_field'])
self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field']) self.assertEqual(site.custom_field_data['longtext_field'], custom_field_data['longtext_field'])
self.assertEqual(site.custom_field_data['number_field'], custom_field_data['number_field']) self.assertEqual(site.custom_field_data['integer_field'], custom_field_data['integer_field'])
self.assertEqual(site.custom_field_data['decimal_field'], custom_field_data['decimal_field'])
self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field']) self.assertEqual(site.custom_field_data['boolean_field'], custom_field_data['boolean_field'])
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'])
@ -763,7 +806,7 @@ class CustomFieldAPITest(APITestCase):
data = { data = {
'custom_fields': { 'custom_fields': {
'text_field': 'ABCD', 'text_field': 'ABCD',
'number_field': 1234, 'integer_field': 1234,
}, },
} }
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
@ -775,8 +818,9 @@ 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'], data['custom_fields']['text_field']) self.assertEqual(response_cf['text_field'], data['custom_fields']['text_field'])
self.assertEqual(response_cf['number_field'], data['custom_fields']['number_field'])
self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(response_cf['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(response_cf['integer_field'], data['custom_fields']['integer_field'])
self.assertEqual(response_cf['decimal_field'], original_cfvs['decimal_field'])
self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(response_cf['boolean_field'], original_cfvs['boolean_field'])
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'])
@ -792,8 +836,9 @@ class CustomFieldAPITest(APITestCase):
# Validate database data # Validate database data
site2.refresh_from_db() site2.refresh_from_db()
self.assertEqual(site2.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(site2.custom_field_data['number_field'], data['custom_fields']['number_field'])
self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field'])
self.assertEqual(site2.custom_field_data['integer_field'], data['custom_fields']['integer_field'])
self.assertEqual(site2.custom_field_data['decimal_field'], original_cfvs['decimal_field'])
self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field'])
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field'])
self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field'])
@ -808,20 +853,20 @@ class CustomFieldAPITest(APITestCase):
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})
self.add_permissions('dcim.change_site') self.add_permissions('dcim.change_site')
cf_integer = CustomField.objects.get(name='number_field') cf_integer = CustomField.objects.get(name='integer_field')
cf_integer.validation_minimum = 10 cf_integer.validation_minimum = 10
cf_integer.validation_maximum = 20 cf_integer.validation_maximum = 20
cf_integer.save() cf_integer.save()
data = {'custom_fields': {'number_field': 9}} data = {'custom_fields': {'integer_field': 9}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
data = {'custom_fields': {'number_field': 21}} data = {'custom_fields': {'integer_field': 21}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
data = {'custom_fields': {'number_field': 15}} data = {'custom_fields': {'integer_field': 15}}
response = self.client.patch(url, data, format='json', **self.header) response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
@ -860,6 +905,7 @@ class CustomFieldImportTest(TestCase):
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT), CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT), CustomField(name='longtext', type=CustomFieldTypeChoices.TYPE_LONGTEXT),
CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER), CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
CustomField(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL),
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN), CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE), CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL), CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
@ -880,10 +926,10 @@ 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', 'cf_multiselect'), ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', '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)
@ -893,10 +939,11 @@ class CustomFieldImportTest(TestCase):
# 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), 9) self.assertEqual(len(site1.custom_field_data), 10)
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)
self.assertEqual(site1.custom_field_data['decimal'], 123.45)
self.assertEqual(site1.custom_field_data['boolean'], True) self.assertEqual(site1.custom_field_data['boolean'], True)
self.assertEqual(site1.custom_field_data['date'], '2020-01-01') self.assertEqual(site1.custom_field_data['date'], '2020-01-01')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
@ -906,10 +953,11 @@ class CustomFieldImportTest(TestCase):
# 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), 9) self.assertEqual(len(site2.custom_field_data), 10)
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)
self.assertEqual(site2.custom_field_data['decimal'], 456.78)
self.assertEqual(site2.custom_field_data['boolean'], False) self.assertEqual(site2.custom_field_data['boolean'], False)
self.assertEqual(site2.custom_field_data['date'], '2020-01-02') self.assertEqual(site2.custom_field_data['date'], '2020-01-02')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
@ -1034,53 +1082,78 @@ class CustomFieldModelFilterTest(TestCase):
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Decimal filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_DECIMAL)
cf.save()
cf.content_types.set([obj_type])
# Boolean filtering # Boolean filtering
cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Exact text filtering # Exact text filtering
cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) name='cf4',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Loose text filtering # Loose text filtering
cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) name='cf5',
type=CustomFieldTypeChoices.TYPE_TEXT,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Date filtering # Date filtering
cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_DATE)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Exact URL filtering # Exact URL filtering
cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) name='cf7',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Loose URL filtering # Loose URL filtering
cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, cf = CustomField(
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) name='cf8',
type=CustomFieldTypeChoices.TYPE_URL,
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Selection filtering # Selection filtering
cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) cf = CustomField(
name='cf9',
type=CustomFieldTypeChoices.TYPE_SELECT,
choices=['Foo', 'Bar', 'Baz']
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Multiselect filtering # Multiselect filtering
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X']) cf = CustomField(
name='cf10',
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
choices=['A', 'B', 'C', 'X']
)
cf.save() cf.save()
cf.content_types.set([obj_type]) cf.content_types.set([obj_type])
# Object filtering # Object filtering
cf = CustomField( cf = CustomField(
name='cf10', name='cf11',
type=CustomFieldTypeChoices.TYPE_OBJECT, type=CustomFieldTypeChoices.TYPE_OBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer) object_type=ContentType.objects.get_for_model(Manufacturer)
) )
@ -1089,7 +1162,7 @@ class CustomFieldModelFilterTest(TestCase):
# Multi-object filtering # Multi-object filtering
cf = CustomField( cf = CustomField(
name='cf11', name='cf12',
type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, type=CustomFieldTypeChoices.TYPE_MULTIOBJECT,
object_type=ContentType.objects.get_for_model(Manufacturer) object_type=ContentType.objects.get_for_model(Manufacturer)
) )
@ -1099,42 +1172,45 @@ class CustomFieldModelFilterTest(TestCase):
Site.objects.bulk_create([ Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', custom_field_data={ Site(name='Site 1', slug='site-1', custom_field_data={
'cf1': 100, 'cf1': 100,
'cf2': True, 'cf2': 100.1,
'cf3': 'foo', 'cf3': True,
'cf4': 'foo', 'cf4': 'foo',
'cf5': '2016-06-26', 'cf5': 'foo',
'cf6': 'http://a.example.com', 'cf6': '2016-06-26',
'cf7': 'http://a.example.com', 'cf7': 'http://a.example.com',
'cf8': 'Foo', 'cf8': 'http://a.example.com',
'cf9': ['A', 'X'], 'cf9': 'Foo',
'cf10': manufacturers[0].pk, 'cf10': ['A', 'X'],
'cf11': [manufacturers[0].pk, manufacturers[3].pk], 'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}), }),
Site(name='Site 2', slug='site-2', custom_field_data={ Site(name='Site 2', slug='site-2', custom_field_data={
'cf1': 200, 'cf1': 200,
'cf2': True, 'cf2': 200.2,
'cf3': 'foobar', 'cf3': True,
'cf4': 'foobar', 'cf4': 'foobar',
'cf5': '2016-06-27', 'cf5': 'foobar',
'cf6': 'http://b.example.com', 'cf6': '2016-06-27',
'cf7': 'http://b.example.com', 'cf7': 'http://b.example.com',
'cf8': 'Bar', 'cf8': 'http://b.example.com',
'cf9': ['B', 'X'], 'cf9': 'Bar',
'cf10': manufacturers[1].pk, 'cf10': ['B', 'X'],
'cf11': [manufacturers[1].pk, manufacturers[3].pk], 'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}), }),
Site(name='Site 3', slug='site-3', custom_field_data={ Site(name='Site 3', slug='site-3', custom_field_data={
'cf1': 300, 'cf1': 300,
'cf2': False, 'cf2': 300.3,
'cf3': 'bar', 'cf3': False,
'cf4': 'bar', 'cf4': 'bar',
'cf5': '2016-06-28', 'cf5': 'bar',
'cf6': 'http://c.example.com', 'cf6': '2016-06-28',
'cf7': 'http://c.example.com', 'cf7': 'http://c.example.com',
'cf8': 'Baz', 'cf8': 'http://c.example.com',
'cf9': ['C', 'X'], 'cf9': 'Baz',
'cf10': manufacturers[2].pk, 'cf10': ['C', 'X'],
'cf11': [manufacturers[2].pk, manufacturers[3].pk], 'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}), }),
]) ])
@ -1146,60 +1222,68 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
def test_filter_decimal(self):
self.assertEqual(self.filterset({'cf_cf2': [100.1, 200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__n': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__gt': [200.2]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2__gte': [200.2]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2__lt': [200.2]}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf2__lte': [200.2]}, self.queryset).qs.count(), 2)
def test_filter_boolean(self): def test_filter_boolean(self):
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf3': True}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf3': False}, self.queryset).qs.count(), 1)
def test_filter_text_strict(self): def test_filter_text_strict(self):
self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__n': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__ic': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__nic': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__isw': ['foo']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__nisw': ['foo']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__iew': ['bar']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__niew': ['bar']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf4__ie': ['FOO']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf4__nie': ['FOO']}, self.queryset).qs.count(), 2)
def test_filter_text_loose(self): def test_filter_text_loose(self):
self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf5': ['foo']}, self.queryset).qs.count(), 2)
def test_filter_date(self): def test_filter_date(self):
self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf6__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf6__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf6__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
def test_filter_url_strict(self): def test_filter_url_strict(self):
self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf7__ic': ['b']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__nic': ['b']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf7__isw': ['http://']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0) self.assertEqual(self.filterset({'cf_cf7__nisw': ['http://']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf7__iew': ['.com']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0) self.assertEqual(self.filterset({'cf_cf7__niew': ['.com']}, self.queryset).qs.count(), 0)
self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1) self.assertEqual(self.filterset({'cf_cf7__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf7__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
def test_filter_url_loose(self): def test_filter_url_loose(self):
self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf8': ['example.com']}, self.queryset).qs.count(), 3)
def test_filter_select(self): def test_filter_select(self):
self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf9': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self): def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
def test_filter_object(self): def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
self.assertEqual(self.filterset({'cf_cf10': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
def test_filter_multiobject(self): def test_filter_multiobject(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True) manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2) self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[0], manufacturer_ids[1]]}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf11': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3) self.assertEqual(self.filterset({'cf_cf12': [manufacturer_ids[3]]}, self.queryset).qs.count(), 3)

View File

@ -2,7 +2,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from circuits.models import Provider from ipam.models import ASN, RIR
from dcim.models import Site from dcim.models import Site
from extras.validators import CustomValidator from extras.validators import CustomValidator
@ -67,21 +67,25 @@ custom_validator = MyValidator()
class CustomValidatorTest(TestCase): class CustomValidatorTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) @classmethod
def setUpTestData(cls):
RIR.objects.create(name='RIR 1', slug='rir-1')
@override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_configuration(self): def test_configuration(self):
self.assertIn('circuits.provider', settings.CUSTOM_VALIDATORS) self.assertIn('ipam.asn', settings.CUSTOM_VALIDATORS)
validator = settings.CUSTOM_VALIDATORS['circuits.provider'][0] validator = settings.CUSTOM_VALIDATORS['ipam.asn'][0]
self.assertIsInstance(validator, CustomValidator) self.assertIsInstance(validator, CustomValidator)
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [min_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [min_validator]})
def test_min(self): def test_min(self):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Provider(name='Provider 1', slug='provider-1', asn=1).clean() ASN(asn=1, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'circuits.provider': [max_validator]}) @override_settings(CUSTOM_VALIDATORS={'ipam.asn': [max_validator]})
def test_max(self): def test_max(self):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
Provider(name='Provider 1', slug='provider-1', asn=65535).clean() ASN(asn=65535, rir=RIR.objects.first()).clean()
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]}) @override_settings(CUSTOM_VALIDATORS={'dcim.site': [min_length_validator]})
def test_min_length(self): def test_min_length(self):

View File

@ -23,6 +23,9 @@ class CustomFieldModelFormTest(TestCase):
cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER) cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
cf_integer.content_types.set([obj_type]) cf_integer.content_types.set([obj_type])
cf_integer = CustomField.objects.create(name='decimal', type=CustomFieldTypeChoices.TYPE_DECIMAL)
cf_integer.content_types.set([obj_type])
cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN) cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
cf_boolean.content_types.set([obj_type]) cf_boolean.content_types.set([obj_type])

View File

@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from extras.plugins import PluginMenu
from extras.registry import registry from extras.registry import registry
from extras.tests.dummy_plugin import config as dummy_config from extras.tests.dummy_plugin import config as dummy_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
@ -58,9 +59,28 @@ class PluginTest(TestCase):
response = client.get(url) response = client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_registered_views(self):
# Test URL resolution
url = reverse('dcim:site_extra', kwargs={'pk': 1})
self.assertEqual(url, '/dcim/sites/1/other-stuff/')
# Test GET request
client = Client()
response = client.get(url)
self.assertEqual(response.status_code, 200)
def test_menu(self):
"""
Check menu registration.
"""
menu = registry['plugins']['menus'][0]
self.assertIsInstance(menu, PluginMenu)
self.assertEqual(menu.label, 'Dummy')
def test_menu_items(self): def test_menu_items(self):
""" """
Check that plugin MenuItems and MenuButtons are registered. Check menu_items registration.
""" """
self.assertIn('Dummy plugin', registry['plugins']['menu_items']) self.assertIn('Dummy plugin', registry['plugins']['menu_items'])
menu_items = registry['plugins']['menu_items']['Dummy plugin'] menu_items = registry['plugins']['menu_items']['Dummy plugin']

View File

@ -1,7 +1,7 @@
from django.urls import path, re_path from django.urls import include, path, re_path
from extras import models, views from extras import views
from netbox.views.generic import ObjectChangeLogView from utilities.urls import get_model_urls
app_name = 'extras' app_name = 'extras'
@ -13,11 +13,7 @@ urlpatterns = [
path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'), path('custom-fields/import/', views.CustomFieldBulkImportView.as_view(), name='customfield_import'),
path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'), path('custom-fields/edit/', views.CustomFieldBulkEditView.as_view(), name='customfield_bulk_edit'),
path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'), path('custom-fields/delete/', views.CustomFieldBulkDeleteView.as_view(), name='customfield_bulk_delete'),
path('custom-fields/<int:pk>/', views.CustomFieldView.as_view(), name='customfield'), path('custom-fields/<int:pk>/', include(get_model_urls('extras', 'customfield'))),
path('custom-fields/<int:pk>/edit/', views.CustomFieldEditView.as_view(), name='customfield_edit'),
path('custom-fields/<int:pk>/delete/', views.CustomFieldDeleteView.as_view(), name='customfield_delete'),
path('custom-fields/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='customfield_changelog',
kwargs={'model': models.CustomField}),
# Custom links # Custom links
path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'), path('custom-links/', views.CustomLinkListView.as_view(), name='customlink_list'),
@ -25,11 +21,7 @@ urlpatterns = [
path('custom-links/import/', views.CustomLinkBulkImportView.as_view(), name='customlink_import'), path('custom-links/import/', views.CustomLinkBulkImportView.as_view(), name='customlink_import'),
path('custom-links/edit/', views.CustomLinkBulkEditView.as_view(), name='customlink_bulk_edit'), path('custom-links/edit/', views.CustomLinkBulkEditView.as_view(), name='customlink_bulk_edit'),
path('custom-links/delete/', views.CustomLinkBulkDeleteView.as_view(), name='customlink_bulk_delete'), path('custom-links/delete/', views.CustomLinkBulkDeleteView.as_view(), name='customlink_bulk_delete'),
path('custom-links/<int:pk>/', views.CustomLinkView.as_view(), name='customlink'), path('custom-links/<int:pk>/', include(get_model_urls('extras', 'customlink'))),
path('custom-links/<int:pk>/edit/', views.CustomLinkEditView.as_view(), name='customlink_edit'),
path('custom-links/<int:pk>/delete/', views.CustomLinkDeleteView.as_view(), name='customlink_delete'),
path('custom-links/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='customlink_changelog',
kwargs={'model': models.CustomLink}),
# Export templates # Export templates
path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'), path('export-templates/', views.ExportTemplateListView.as_view(), name='exporttemplate_list'),
@ -37,11 +29,7 @@ urlpatterns = [
path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'), path('export-templates/import/', views.ExportTemplateBulkImportView.as_view(), name='exporttemplate_import'),
path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'), path('export-templates/edit/', views.ExportTemplateBulkEditView.as_view(), name='exporttemplate_bulk_edit'),
path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'), path('export-templates/delete/', views.ExportTemplateBulkDeleteView.as_view(), name='exporttemplate_bulk_delete'),
path('export-templates/<int:pk>/', views.ExportTemplateView.as_view(), name='exporttemplate'), path('export-templates/<int:pk>/', include(get_model_urls('extras', 'exporttemplate'))),
path('export-templates/<int:pk>/edit/', views.ExportTemplateEditView.as_view(), name='exporttemplate_edit'),
path('export-templates/<int:pk>/delete/', views.ExportTemplateDeleteView.as_view(), name='exporttemplate_delete'),
path('export-templates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='exporttemplate_changelog',
kwargs={'model': models.ExportTemplate}),
# Webhooks # Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'), path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
@ -49,11 +37,7 @@ urlpatterns = [
path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'), path('webhooks/import/', views.WebhookBulkImportView.as_view(), name='webhook_import'),
path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'), path('webhooks/edit/', views.WebhookBulkEditView.as_view(), name='webhook_bulk_edit'),
path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'), path('webhooks/delete/', views.WebhookBulkDeleteView.as_view(), name='webhook_bulk_delete'),
path('webhooks/<int:pk>/', views.WebhookView.as_view(), name='webhook'), path('webhooks/<int:pk>/', include(get_model_urls('extras', 'webhook'))),
path('webhooks/<int:pk>/edit/', views.WebhookEditView.as_view(), name='webhook_edit'),
path('webhooks/<int:pk>/delete/', views.WebhookDeleteView.as_view(), name='webhook_delete'),
path('webhooks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='webhook_changelog',
kwargs={'model': models.Webhook}),
# Tags # Tags
path('tags/', views.TagListView.as_view(), name='tag_list'), path('tags/', views.TagListView.as_view(), name='tag_list'),
@ -61,42 +45,29 @@ urlpatterns = [
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<int:pk>/', views.TagView.as_view(), name='tag'), path('tags/<int:pk>/', include(get_model_urls('extras', 'tag'))),
path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tag_changelog',
kwargs={'model': models.Tag}),
# Config contexts # Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'), path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'), path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
path('config-contexts/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='configcontext_changelog',
kwargs={'model': models.ConfigContext}),
# Image attachments # Image attachments
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Journal entries # Journal entries
path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'), path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'),
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
path('journal-entries/<int:pk>/', views.JournalEntryView.as_view(), name='journalentry'), path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
path('journal-entries/<int:pk>/edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'),
path('journal-entries/<int:pk>/delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'),
path('journal-entries/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='journalentry_changelog',
kwargs={'model': models.JournalEntry}),
# Change logging # Change logging
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'), path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
# Reports # Reports
path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/', views.ReportListView.as_view(), name='report_list'),

View File

@ -12,7 +12,7 @@ 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.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import JobResultStatusChoices from .choices import JobResultStatusChoices
from .forms.reports import ReportForm from .forms.reports import ReportForm
@ -32,15 +32,18 @@ class CustomFieldListView(generic.ObjectListView):
table = tables.CustomFieldTable table = tables.CustomFieldTable
@register_model_view(CustomField)
class CustomFieldView(generic.ObjectView): class CustomFieldView(generic.ObjectView):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
@register_model_view(CustomField, 'edit')
class CustomFieldEditView(generic.ObjectEditView): class CustomFieldEditView(generic.ObjectEditView):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
form = forms.CustomFieldForm form = forms.CustomFieldForm
@register_model_view(CustomField, 'delete')
class CustomFieldDeleteView(generic.ObjectDeleteView): class CustomFieldDeleteView(generic.ObjectDeleteView):
queryset = CustomField.objects.all() queryset = CustomField.objects.all()
@ -75,15 +78,18 @@ class CustomLinkListView(generic.ObjectListView):
table = tables.CustomLinkTable table = tables.CustomLinkTable
@register_model_view(CustomLink)
class CustomLinkView(generic.ObjectView): class CustomLinkView(generic.ObjectView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'edit')
class CustomLinkEditView(generic.ObjectEditView): class CustomLinkEditView(generic.ObjectEditView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
form = forms.CustomLinkForm form = forms.CustomLinkForm
@register_model_view(CustomLink, 'delete')
class CustomLinkDeleteView(generic.ObjectDeleteView): class CustomLinkDeleteView(generic.ObjectDeleteView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
@ -118,15 +124,18 @@ class ExportTemplateListView(generic.ObjectListView):
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
@register_model_view(ExportTemplate)
class ExportTemplateView(generic.ObjectView): class ExportTemplateView(generic.ObjectView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'edit')
class ExportTemplateEditView(generic.ObjectEditView): class ExportTemplateEditView(generic.ObjectEditView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
form = forms.ExportTemplateForm form = forms.ExportTemplateForm
@register_model_view(ExportTemplate, 'delete')
class ExportTemplateDeleteView(generic.ObjectDeleteView): class ExportTemplateDeleteView(generic.ObjectDeleteView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
@ -161,15 +170,18 @@ class WebhookListView(generic.ObjectListView):
table = tables.WebhookTable table = tables.WebhookTable
@register_model_view(Webhook)
class WebhookView(generic.ObjectView): class WebhookView(generic.ObjectView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
@register_model_view(Webhook, 'edit')
class WebhookEditView(generic.ObjectEditView): class WebhookEditView(generic.ObjectEditView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
form = forms.WebhookForm form = forms.WebhookForm
@register_model_view(Webhook, 'delete')
class WebhookDeleteView(generic.ObjectDeleteView): class WebhookDeleteView(generic.ObjectDeleteView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
@ -206,6 +218,7 @@ class TagListView(generic.ObjectListView):
table = tables.TagTable table = tables.TagTable
@register_model_view(Tag)
class TagView(generic.ObjectView): class TagView(generic.ObjectView):
queryset = Tag.objects.all() queryset = Tag.objects.all()
@ -231,11 +244,13 @@ class TagView(generic.ObjectView):
} }
@register_model_view(Tag, 'edit')
class TagEditView(generic.ObjectEditView): class TagEditView(generic.ObjectEditView):
queryset = Tag.objects.all() queryset = Tag.objects.all()
form = forms.TagForm form = forms.TagForm
@register_model_view(Tag, 'delete')
class TagDeleteView(generic.ObjectDeleteView): class TagDeleteView(generic.ObjectDeleteView):
queryset = Tag.objects.all() queryset = Tag.objects.all()
@ -273,6 +288,7 @@ class ConfigContextListView(generic.ObjectListView):
actions = ('add', 'bulk_edit', 'bulk_delete') actions = ('add', 'bulk_edit', 'bulk_delete')
@register_model_view(ConfigContext)
class ConfigContextView(generic.ObjectView): class ConfigContextView(generic.ObjectView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
@ -310,6 +326,7 @@ class ConfigContextView(generic.ObjectView):
} }
@register_model_view(ConfigContext, 'edit')
class ConfigContextEditView(generic.ObjectEditView): class ConfigContextEditView(generic.ObjectEditView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
form = forms.ConfigContextForm form = forms.ConfigContextForm
@ -322,6 +339,7 @@ class ConfigContextBulkEditView(generic.BulkEditView):
form = forms.ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
@register_model_view(ConfigContext, 'delete')
class ConfigContextDeleteView(generic.ObjectDeleteView): class ConfigContextDeleteView(generic.ObjectDeleteView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
@ -353,7 +371,6 @@ class ObjectConfigContextView(generic.ObjectView):
'source_contexts': source_contexts, 'source_contexts': source_contexts,
'format': format, 'format': format,
'base_template': self.base_template, 'base_template': self.base_template,
'active_tab': 'config-context',
} }
@ -370,6 +387,7 @@ class ObjectChangeListView(generic.ObjectListView):
actions = ('export',) actions = ('export',)
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView): class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.all() queryset = ObjectChange.objects.all()
@ -427,6 +445,7 @@ class ObjectChangeView(generic.ObjectView):
# Image attachments # Image attachments
# #
@register_model_view(ImageAttachment, 'edit')
class ImageAttachmentEditView(generic.ObjectEditView): class ImageAttachmentEditView(generic.ObjectEditView):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
form = forms.ImageAttachmentForm form = forms.ImageAttachmentForm
@ -449,6 +468,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
} }
@register_model_view(ImageAttachment, 'delete')
class ImageAttachmentDeleteView(generic.ObjectDeleteView): class ImageAttachmentDeleteView(generic.ObjectDeleteView):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
@ -468,10 +488,12 @@ class JournalEntryListView(generic.ObjectListView):
actions = ('export', 'bulk_edit', 'bulk_delete') actions = ('export', 'bulk_edit', 'bulk_delete')
@register_model_view(JournalEntry)
class JournalEntryView(generic.ObjectView): class JournalEntryView(generic.ObjectView):
queryset = JournalEntry.objects.all() queryset = JournalEntry.objects.all()
@register_model_view(JournalEntry, 'edit')
class JournalEntryEditView(generic.ObjectEditView): class JournalEntryEditView(generic.ObjectEditView):
queryset = JournalEntry.objects.all() queryset = JournalEntry.objects.all()
form = forms.JournalEntryForm form = forms.JournalEntryForm
@ -489,6 +511,7 @@ class JournalEntryEditView(generic.ObjectEditView):
return reverse(viewname, kwargs={'pk': obj.pk}) return reverse(viewname, kwargs={'pk': obj.pk})
@register_model_view(JournalEntry, 'delete')
class JournalEntryDeleteView(generic.ObjectDeleteView): class JournalEntryDeleteView(generic.ObjectDeleteView):
queryset = JournalEntry.objects.all() queryset = JournalEntry.objects.all()

View File

@ -123,7 +123,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer):
class Meta: class Meta:
model = FHRPGroup model = FHRPGroup
fields = [ fields = [
'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses',
'tags', 'custom_fields', 'created', 'last_updated', 'tags', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -653,13 +653,14 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = FHRPGroup model = FHRPGroup
fields = ['id', 'group_id', 'auth_key'] fields = ['id', 'group_id', 'name', 'auth_key']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(description__icontains=value) Q(description__icontains=value) |
Q(name__icontains=value)
) )
def filter_related_ip(self, queryset, name, value): def filter_related_ip(self, queryset, name, value):

View File

@ -321,6 +321,10 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False, required=False,
label='Authentication key' label='Authentication key'
) )
name = forms.CharField(
max_length=100,
required=False
)
description = forms.CharField( description = forms.CharField(
max_length=200, max_length=200,
required=False required=False
@ -328,10 +332,10 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
model = FHRPGroup model = FHRPGroup
fieldsets = ( fieldsets = (
(None, ('protocol', 'group_id', 'description')), (None, ('protocol', 'group_id', 'name', 'description')),
('Authentication', ('auth_type', 'auth_key')), ('Authentication', ('auth_type', 'auth_key')),
) )
nullable_fields = ('auth_type', 'auth_key', 'description') nullable_fields = ('auth_type', 'auth_key', 'name', 'description')
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):

View File

@ -326,7 +326,7 @@ class FHRPGroupCSVForm(NetBoxModelCSVForm):
class Meta: class Meta:
model = FHRPGroup model = FHRPGroup
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description')
class VLANGroupCSVForm(NetBoxModelCSVForm): class VLANGroupCSVForm(NetBoxModelCSVForm):

View File

@ -335,9 +335,12 @@ class FHRPGroupFilterForm(NetBoxModelFilterSetForm):
model = FHRPGroup model = FHRPGroup
fieldsets = ( fieldsets = (
(None, ('q', 'tag')), (None, ('q', 'tag')),
('Attributes', ('protocol', 'group_id')), ('Attributes', ('name', 'protocol', 'group_id')),
('Authentication', ('auth_type', 'auth_key')), ('Authentication', ('auth_type', 'auth_key')),
) )
name = forms.CharField(
required=False
)
protocol = MultipleChoiceField( protocol = MultipleChoiceField(
choices=FHRPGroupProtocolChoices, choices=FHRPGroupProtocolChoices,
required=False required=False

View File

@ -527,7 +527,7 @@ class FHRPGroupForm(NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), ('FHRP Group', ('protocol', 'group_id', 'name', 'description', 'tags')),
('Authentication', ('auth_type', 'auth_key')), ('Authentication', ('auth_type', 'auth_key')),
('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status'))
) )
@ -535,7 +535,7 @@ class FHRPGroupForm(NetBoxModelForm):
class Meta: class Meta:
model = FHRPGroup model = FHRPGroup
fields = ( fields = (
'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', 'protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

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