diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 11b7e9aff..907ad6cf7 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.3.4
+ placeholder: v3.3.5
validations:
required: true
- type: dropdown
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index bc00a3921..3cd9bc4ee 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yaml
@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
- placeholder: v3.3.4
+ placeholder: v3.3.5
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 67f5028cd..d75f98fbc 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,7 @@
name: CI
on: [push, pull_request]
+permissions:
+ contents: read
jobs:
build:
runs-on: ubuntu-latest
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index 9df4bc441..6019cef5d 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -4,6 +4,11 @@ name: 'Lock threads'
on:
schedule:
- cron: '0 3 * * *'
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ pull-requests: write
jobs:
lock:
@@ -11,7 +16,6 @@ jobs:
steps:
- uses: dessant/lock-threads@v3
with:
- github-token: ${{ github.token }}
issue-inactive-days: 90
pr-inactive-days: 30
issue-lock-reason: 'resolved'
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index 57666417a..ab259af2a 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -1,14 +1,21 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs'
+
on:
schedule:
- cron: '0 4 * * *'
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ pull-requests: write
jobs:
stale:
+
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v5
+ - uses: actions/stale@v6
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an
diff --git a/base_requirements.txt b/base_requirements.txt
index 363f97b31..4032bc26e 100644
--- a/base_requirements.txt
+++ b/base_requirements.txt
@@ -4,7 +4,7 @@ bleach
# The Python web framework on which NetBox is built
# https://github.com/django/django
-Django<4.1
+Django<4.2
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers
@@ -68,7 +68,7 @@ drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django
-graphene_django
+graphene_django<3.0
# WSGI HTTP server
# https://gunicorn.org/
@@ -80,7 +80,8 @@ Jinja2
# Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown
-Markdown
+# mkdocs currently requires Markdown v3.3
+Markdown<3.4
# File inclusion plugin for Python-Markdown
# https://github.com/cmacmackin/markdown-include
diff --git a/docs/_theme/main.html b/docs/_theme/main.html
index 4dfc4e14e..3ff44b9cb 100644
--- a/docs/_theme/main.html
+++ b/docs/_theme/main.html
@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
- {# Disable search indexing unless we're building for ReadTheDocs #}
- {% if not config.extra.readthedocs %}
+ {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
+ {% if page.canonical_url != 'https://docs.netbox.dev/' %}
{% endif %}
{% endblock %}
diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md
index a62d14fef..15f743754 100644
--- a/docs/configuration/required-parameters.md
+++ b/docs/configuration/required-parameters.md
@@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## 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
* `USER` - PostgreSQL username
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 21607e566..93f8fa902 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
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
HTTP_PROXIES = {
diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md
index c443fa9f6..81aaa5247 100644
--- a/docs/customization/custom-fields.md
+++ b/docs/customization/custom-fields.md
@@ -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)
* Long text: Free-form of any length; supports Markdown rendering
* Integer: A whole number (positive or negative)
+* Decimal: A fixed-precision decimal number (4 decimal places)
* Boolean: True or false
* Date: A date in ISO 8601 format (YYYY-MM-DD)
* URL: This will be presented as a link in the web UI
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index 230b003c6..e5d5a1ef5 100644
--- a/docs/customization/custom-scripts.md
+++ b/docs/customization/custom-scripts.md
@@ -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.
+## 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
### Default Options
diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md
index f4d171f48..aef11d666 100644
--- a/docs/development/adding-models.md
+++ b/docs/development/adding-models.md
@@ -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
-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
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index a6aa27b1b..583a4f3e9 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -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).
-!!! warning "PostgreSQL 10 or later required"
- NetBox requires PostgreSQL 10 or later. Please note that MySQL and other relational databases are **not** supported.
+!!! warning "PostgreSQL 11 or later required"
+ NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@@ -35,7 +35,7 @@ sudo systemctl start 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
psql -V
diff --git a/docs/installation/index.md b/docs/installation/index.md
index 8b588fccd..49163550d 100644
--- a/docs/installation/index.md
+++ b/docs/installation/index.md
@@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.8 |
-| PostgreSQL | 10 |
+| PostgreSQL | 11 |
| Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference:
diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md
index 802c13e49..cc49cd30e 100644
--- a/docs/installation/upgrading.md
+++ b/docs/installation/upgrading.md
@@ -20,7 +20,7 @@ NetBox v3.0 and later require the following:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.8 |
-| PostgreSQL | 10 |
+| PostgreSQL | 11 |
| Redis | 4.0 |
## 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.
!!! 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
-command:
+If you are not sure how NetBox was installed originally, check with this command:
```
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
exist. If it was installed from git, then `/opt/netbox` and
`/opt/netbox/.git` will both exist as normal directories.
diff --git a/docs/introduction.md b/docs/introduction.md
index cffcb37dd..fe82e68aa 100644
--- a/docs/introduction.md
+++ b/docs/introduction.md
@@ -74,6 +74,6 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
-| Database | PostgreSQL 10+ |
+| Database | PostgreSQL 11+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM (optional) |
diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md
index 050f93244..6dc4aa13e 100644
--- a/docs/models/dcim/devicetype.md
+++ b/docs/models/dcim/devicetype.md
@@ -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).
+### Weight
+
+The numeric weight of the device, including a unit designation (e.g. 10 kilograms or 20 pounds).
+
### 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.
diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md
index b8ec0ac6e..3122d2e00 100644
--- a/docs/models/dcim/moduletype.md
+++ b/docs/models/dcim/moduletype.md
@@ -35,3 +35,7 @@ The model number assigned to this module type by its manufacturer. Must be uniqu
### Part Number
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).
diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md
index 57e7bec98..e88c36fad 100644
--- a/docs/models/dcim/rack.md
+++ b/docs/models/dcim/rack.md
@@ -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.
+### Weight
+
+The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
+
### 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.)
diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md
index 4da390310..de09fee29 100644
--- a/docs/models/ipam/fhrpgroup.md
+++ b/docs/models/ipam/fhrpgroup.md
@@ -19,6 +19,10 @@ The wire protocol employed by cooperating servers to maintain the virtual [IP ad
The group's numeric identifier.
+### Name
+
+An optional name for the FHRP group.
+
### Authentication Type
The type of authentication employed by group nodes, if any.
diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md
index 98db9e0bb..b7b03b5bf 100644
--- a/docs/plugins/development/index.md
+++ b/docs/plugins/development/index.md
@@ -14,6 +14,7 @@ Plugins can do a lot, including:
* Provide their own "pages" (views) in the web user interface
* Inject template content and navigation links
* Extend NetBox's REST and GraphQL APIs
+* Load additional Django apps
* 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.
@@ -82,6 +83,7 @@ class FooBarConfig(PluginConfig):
default_settings = {
'baz': True
}
+ django_apps = ["foo", "bar", "baz"]
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. |
| `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 |
+| `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 |
| `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 |
@@ -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.
+!!! 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
`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:
diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md
index c58621b81..16f5dd0df 100644
--- a/docs/plugins/development/models.md
+++ b/docs/plugins/development/models.md
@@ -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
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.CloningMixin
+
::: netbox.models.features.CustomLinksMixin
::: netbox.models.features.CustomFieldsMixin
diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md
index 52ae953a7..a52a9803a 100644
--- a/docs/plugins/development/navigation.md
+++ b/docs/plugins/development/navigation.md
@@ -1,25 +1,67 @@
# 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
-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
- The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance.
-
-```python
+```python filename="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
-menu_items = (
- PluginMenuItem(
- link='plugins:netbox_animal_sounds:random_animal',
- link_text='Random sound',
- buttons=(
- PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
- PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
- )
- ),
+item1 = PluginMenuItem(
+ link='plugins:myplugin:myview',
+ link_text='Some text',
+ buttons=(
+ PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE),
+ PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN),
+ )
)
```
@@ -34,17 +76,19 @@ A `PluginMenuItem` has the following attributes:
## 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:
| Attribute | Required | Description |
|---------------|----------|--------------------------------------------------------------------|
| `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) |
-| `icon_class` | Yes | Button icon CSS class* |
+| `icon_class` | Yes | Button icon CSS class |
| `color` | - | One of the choices provided by `ButtonColorChoices` |
| `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
- 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.
+!!! tip
+ Supported icons can be found at [Material Design Icons](https://materialdesignicons.com/)
diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md
index cabcd7045..dfada7a42 100644
--- a/docs/plugins/development/views.md
+++ b/docs/plugins/development/views.md
@@ -148,6 +148,32 @@ These views are provided to enable or enhance certain NetBox model features, suc
## 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:
* `left_page()` - Inject content on the left side of the page
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index d779e1a93..2e25a9589 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -1,6 +1,34 @@
# 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
---
diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
new file mode 100644
index 000000000..bbb386577
--- /dev/null
+++ b/docs/release-notes/version-3.4.md
@@ -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
diff --git a/mkdocs.yml b/mkdocs.yml
index 530c6d52e..4e2cb73dd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -38,7 +38,6 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
- readthedocs: !ENV READTHEDOCS
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox
@@ -252,6 +251,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- 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.2: 'release-notes/version-3.2.md'
- Version 3.1: 'release-notes/version-3.1.md'
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index c1d856f39..4a8e2bd28 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -31,7 +31,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta:
model = Provider
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',
]
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index cee38fb18..cf250584f 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -65,7 +65,7 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class Meta:
model = Provider
- fields = ['id', 'name', 'slug', 'asn', 'account']
+ fields = ['id', 'name', 'slug', 'account']
def search(self, queryset, name, value):
if not value.strip():
@@ -73,8 +73,6 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(
Q(name__icontains=value) |
Q(account__icontains=value) |
- Q(noc_contact__icontains=value) |
- Q(admin_contact__icontains=value) |
Q(comments__icontains=value)
)
diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py
index b6ba42afb..12975b5d6 100644
--- a/netbox/circuits/forms/bulk_edit.py
+++ b/netbox/circuits/forms/bulk_edit.py
@@ -20,10 +20,6 @@ __all__ = (
class ProviderBulkEditForm(NetBoxModelBulkEditForm):
- asn = forms.IntegerField(
- required=False,
- label='ASN (legacy)'
- )
asns = DynamicModelMultipleChoiceField(
queryset=ASN.objects.all(),
label=_('ASNs'),
@@ -34,20 +30,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False,
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(
widget=SmallTextarea,
label='Comments'
@@ -55,10 +37,10 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
model = Provider
fieldsets = (
- (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')),
+ (None, ('asns', 'account', )),
)
nullable_fields = (
- 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+ 'asns', 'account', 'comments',
)
diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py
index cc2d0409a..77ebb3de9 100644
--- a/netbox/circuits/forms/bulk_import.py
+++ b/netbox/circuits/forms/bulk_import.py
@@ -18,7 +18,7 @@ class ProviderCSVForm(NetBoxModelCSVForm):
class Meta:
model = Provider
fields = (
- 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+ 'name', 'slug', 'account', 'comments',
)
diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py
index 7bd7abbbf..17c2e7480 100644
--- a/netbox/circuits/forms/models.py
+++ b/netbox/circuits/forms/models.py
@@ -30,29 +30,17 @@ class ProviderForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
- ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')),
- ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')),
+ ('Provider', ('name', 'slug', 'asns', 'tags')),
+ ('Support Info', ('account',)),
)
class Meta:
model = Provider
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 = {
'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",
}
diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py
index 851f40a22..971233162 100644
--- a/netbox/circuits/migrations/0001_squashed.py
+++ b/netbox/circuits/migrations/0001_squashed.py
@@ -1,5 +1,5 @@
import dcim.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('cid', models.CharField(max_length=100)),
('status', models.CharField(default='active', max_length=50)),
@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -73,7 +73,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -93,7 +93,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('description', models.CharField(blank=True, max_length=200)),
diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py
index c686bf042..96b2a9d97 100644
--- a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py
+++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py
@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import taggit.managers
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='circuittermination',
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(
model_name='circuittermination',
diff --git a/netbox/circuits/migrations/0039_unique_constraints.py b/netbox/circuits/migrations/0039_unique_constraints.py
new file mode 100644
index 000000000..1d5b62499
--- /dev/null
+++ b/netbox/circuits/migrations/0039_unique_constraints.py
@@ -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'),
+ ),
+ ]
diff --git a/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py
new file mode 100644
index 000000000..98c82204d
--- /dev/null
+++ b/netbox/circuits/migrations/0040_provider_remove_deprecated_fields.py
@@ -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',
+ ),
+ ]
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index c08b5473a..ea74eeb40 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -132,7 +132,12 @@ class Circuit(NetBoxModel):
class Meta:
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):
return self.cid
@@ -208,7 +213,12 @@ class CircuitTermination(
class Meta:
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):
return f'Termination {self.term_side}: {self.site or self.provider_network}'
diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py
index e136e13ea..bd63ff0c6 100644
--- a/netbox/circuits/models/providers.py
+++ b/netbox/circuits/models/providers.py
@@ -24,12 +24,6 @@ class Provider(NetBoxModel):
max_length=100,
unique=True
)
- asn = ASNField(
- blank=True,
- null=True,
- verbose_name='ASN',
- help_text='32-bit autonomous system number'
- )
asns = models.ManyToManyField(
to='ipam.ASN',
related_name='providers',
@@ -40,18 +34,6 @@ class Provider(NetBoxModel):
blank=True,
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(
blank=True
)
@@ -62,7 +44,7 @@ class Provider(NetBoxModel):
)
clone_fields = (
- 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
+ 'account',
)
class Meta:
@@ -106,10 +88,9 @@ class ProviderNetwork(NetBoxModel):
constraints = (
models.UniqueConstraint(
fields=('provider', 'name'),
- name='circuits_providernetwork_provider_name'
+ name='%(app_label)s_%(class)s_unique_provider_name'
),
)
- unique_together = ('provider', 'name')
def __str__(self):
return self.name
diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py
index 0ec6d439d..3e2fd1193 100644
--- a/netbox/circuits/tables/providers.py
+++ b/netbox/circuits/tables/providers.py
@@ -41,10 +41,10 @@ class ProviderTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Provider
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',
)
- default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
+ default_columns = ('pk', 'name', 'account', 'circuit_count')
class ProviderNetworkTable(NetBoxTable):
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index 02b489ac4..c9d2cfc40 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -20,7 +20,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider
brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
bulk_update_data = {
- 'asn': 1234,
+ 'account': '1234',
}
@classmethod
diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py
index 2646de3c2..897c87c05 100644
--- a/netbox/circuits/tests/test_filtersets.py
+++ b/netbox/circuits/tests/test_filtersets.py
@@ -25,11 +25,11 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns)
providers = (
- Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'),
- Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'),
- Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'),
- Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'),
- Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'),
+ Provider(name='Provider 1', slug='provider-1', account='1234'),
+ Provider(name='Provider 2', slug='provider-2', account='2345'),
+ Provider(name='Provider 3', slug='provider-3', account='3456'),
+ Provider(name='Provider 4', slug='provider-4', account='4567'),
+ Provider(name='Provider 5', slug='provider-5', account='5678'),
)
Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0]])
@@ -82,10 +82,6 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['provider-1', 'provider-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
asns = ASN.objects.all()[:2]
params = {'asn_id': [asns[0].pk, asns[1].pk]}
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index fa6146b93..9644c0b02 100644
--- a/netbox/circuits/tests/test_views.py
+++ b/netbox/circuits/tests/test_views.py
@@ -23,9 +23,9 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ASN.objects.bulk_create(asns)
providers = (
- Provider(name='Provider 1', slug='provider-1', asn=65001),
- Provider(name='Provider 2', slug='provider-2', asn=65002),
- Provider(name='Provider 3', slug='provider-3', asn=65003),
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
providers[0].asns.set([asns[0], asns[1]])
@@ -37,12 +37,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'name': 'Provider X',
'slug': 'provider-x',
- 'asn': 65123,
'asns': [asns[6].pk, asns[7].pk],
'account': '1234',
- 'portal_url': 'http://example.com/portal',
- 'noc_contact': 'noc@example.com',
- 'admin_contact': 'admin@example.com',
'comments': 'Another provider',
'tags': [t.pk for t in tags],
}
@@ -55,11 +51,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
cls.bulk_edit_data = {
- 'asn': 65009,
'account': '5678',
- 'portal_url': 'http://example.com/portal2',
- 'noc_contact': 'noc2@example.com',
- 'admin_contact': 'admin2@example.com',
'comments': 'New comments',
}
@@ -104,8 +96,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
providers = (
- Provider(name='Provider 1', slug='provider-1', asn=65001),
- Provider(name='Provider 2', slug='provider-2', asn=65002),
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
)
Provider.objects.bulk_create(providers)
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 5b15b29ac..d8c5ea276 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -1,9 +1,7 @@
-from django.urls import path
+from django.urls import include, path
-from dcim.views import PathTraceView
-from netbox.views.generic import ObjectChangeLogView, ObjectJournalView
+from utilities.urls import get_model_urls
from . import views
-from .models import *
app_name = 'circuits'
urlpatterns = [
@@ -14,11 +12,7 @@ urlpatterns = [
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
- path('providers//', views.ProviderView.as_view(), name='provider'),
- path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'),
- path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
- path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
- path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}),
+ path('providers//', include(get_model_urls('circuits', 'provider'))),
# Provider networks
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/edit/', views.ProviderNetworkBulkEditView.as_view(), name='providernetwork_bulk_edit'),
path('provider-networks/delete/', views.ProviderNetworkBulkDeleteView.as_view(), name='providernetwork_bulk_delete'),
- path('provider-networks//', views.ProviderNetworkView.as_view(), name='providernetwork'),
- path('provider-networks//edit/', views.ProviderNetworkEditView.as_view(), name='providernetwork_edit'),
- path('provider-networks//delete/', views.ProviderNetworkDeleteView.as_view(), name='providernetwork_delete'),
- path('provider-networks//changelog/', ObjectChangeLogView.as_view(), name='providernetwork_changelog', kwargs={'model': ProviderNetwork}),
- path('provider-networks//journal/', ObjectJournalView.as_view(), name='providernetwork_journal', kwargs={'model': ProviderNetwork}),
+ path('provider-networks//', include(get_model_urls('circuits', 'providernetwork'))),
# Circuit types
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/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
- path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'),
- path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
- path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
- path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
+ path('circuit-types//', include(get_model_urls('circuits', 'circuittype'))),
# Circuits
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/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
- path('circuits//', views.CircuitView.as_view(), name='circuit'),
- path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
- path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
- path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
- path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}),
path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
+ path('circuits//', include(get_model_urls('circuits', 'circuit'))),
# Circuit terminations
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
- path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
- path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
- path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
+ path('circuit-terminations//', include(get_model_urls('circuits', 'circuittermination'))),
]
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 423bd67d6..dc809666b 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -3,9 +3,11 @@ from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
+from dcim.views import PathTraceView
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.utils import count_related
+from utilities.views import register_model_view
from . import filtersets, forms, tables
from .models import *
@@ -23,6 +25,7 @@ class ProviderListView(generic.ObjectListView):
table = tables.ProviderTable
+@register_model_view(Provider)
class ProviderView(generic.ObjectView):
queryset = Provider.objects.all()
@@ -41,11 +44,13 @@ class ProviderView(generic.ObjectView):
}
+@register_model_view(Provider, 'edit')
class ProviderEditView(generic.ObjectEditView):
queryset = Provider.objects.all()
form = forms.ProviderForm
+@register_model_view(Provider, 'delete')
class ProviderDeleteView(generic.ObjectDeleteView):
queryset = Provider.objects.all()
@@ -84,6 +89,7 @@ class ProviderNetworkListView(generic.ObjectListView):
table = tables.ProviderNetworkTable
+@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView):
queryset = ProviderNetwork.objects.all()
@@ -103,11 +109,13 @@ class ProviderNetworkView(generic.ObjectView):
}
+@register_model_view(ProviderNetwork, 'edit')
class ProviderNetworkEditView(generic.ObjectEditView):
queryset = ProviderNetwork.objects.all()
form = forms.ProviderNetworkForm
+@register_model_view(ProviderNetwork, 'delete')
class ProviderNetworkDeleteView(generic.ObjectDeleteView):
queryset = ProviderNetwork.objects.all()
@@ -144,6 +152,7 @@ class CircuitTypeListView(generic.ObjectListView):
table = tables.CircuitTypeTable
+@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all()
@@ -157,11 +166,13 @@ class CircuitTypeView(generic.ObjectView):
}
+@register_model_view(CircuitType, 'edit')
class CircuitTypeEditView(generic.ObjectEditView):
queryset = CircuitType.objects.all()
form = forms.CircuitTypeForm
+@register_model_view(CircuitType, 'delete')
class CircuitTypeDeleteView(generic.ObjectDeleteView):
queryset = CircuitType.objects.all()
@@ -202,15 +213,18 @@ class CircuitListView(generic.ObjectListView):
table = tables.CircuitTable
+@register_model_view(Circuit)
class CircuitView(generic.ObjectView):
queryset = Circuit.objects.all()
+@register_model_view(Circuit, 'edit')
class CircuitEditView(generic.ObjectEditView):
queryset = Circuit.objects.all()
form = forms.CircuitForm
+@register_model_view(Circuit, 'delete')
class CircuitDeleteView(generic.ObjectDeleteView):
queryset = Circuit.objects.all()
@@ -318,11 +332,17 @@ class CircuitSwapTerminations(generic.ObjectEditView):
# Circuit terminations
#
+@register_model_view(CircuitTermination, 'edit')
class CircuitTerminationEditView(generic.ObjectEditView):
queryset = CircuitTermination.objects.all()
form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
+@register_model_view(CircuitTermination, 'delete')
class CircuitTerminationDeleteView(generic.ObjectDeleteView):
queryset = CircuitTermination.objects.all()
+
+
+# Trace view
+register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 897ee4ca3..22d56565e 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -201,6 +201,7 @@ class RackSerializer(NetBoxModelSerializer):
default=None)
width = ChoiceField(choices=RackWidthChoices, 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)
powerfeed_count = serializers.IntegerField(read_only=True)
@@ -208,8 +209,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack
fields = [
'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',
- 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
+ 'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
+ '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)
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)
class Meta:
model = DeviceType
fields = [
'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',
- 'last_updated', 'device_count',
+ 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'device_count',
]
class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer()
+ weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
# module_count = serializers.IntegerField(read_only=True)
class Meta:
model = ModuleType
fields = [
- 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields',
- 'created', 'last_updated',
+ 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated',
]
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 7d35a40f9..8466d4861 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -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
#
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 0a4439173..a0c5e545c 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -320,7 +320,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'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):
@@ -482,7 +482,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = DeviceType
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):
@@ -576,7 +576,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
- fields = ['id', 'model', 'part_number']
+ fields = ['id', 'model', 'part_number', 'weight', 'weight_unit']
def search(self, queryset, name, value):
if not value.strip():
@@ -887,6 +887,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug',
label='Device model (slug)',
)
+ name = MultiValueCharFilter(
+ lookup_expr='iexact'
+ )
status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices,
null_value=None
@@ -950,7 +953,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
class Meta:
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):
if not value.strip():
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 396f7e59b..d033d3a67 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -285,15 +285,26 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
widget=SmallTextarea,
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
fieldsets = (
('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
('Location', ('region', 'site_group', 'site', 'location')),
('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
+ ('Weight', ('weight', 'weight_unit')),
)
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,
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
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):
@@ -371,12 +393,23 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
part_number = forms.CharField(
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
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):
@@ -553,17 +586,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
'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):
domain = forms.CharField(
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 96b0d1319..818da83e1 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -228,6 +228,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
+ ('Weight', ('weight', 'weight_unit')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -281,6 +282,13 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False
)
tag = TagFilterField(model)
+ weight = forms.DecimalField(
+ required=False
+ )
+ weight_unit = forms.ChoiceField(
+ choices=add_blank_choice(WeightUnitChoices),
+ required=False
+ )
class RackElevationFilterForm(RackFilterForm):
@@ -370,6 +378,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)),
+ ('Weight', ('weight', 'weight_unit')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -465,6 +474,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)
)
tag = TagFilterField(model)
+ weight = forms.DecimalField(
+ required=False
+ )
+ weight_unit = forms.ChoiceField(
+ choices=add_blank_choice(WeightUnitChoices),
+ required=False
+ )
class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
@@ -476,6 +492,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports',
)),
+ ('Weight', ('weight', 'weight_unit')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -529,6 +546,13 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)
)
tag = TagFilterField(model)
+ weight = forms.DecimalField(
+ required=False
+ )
+ weight_unit = forms.ChoiceField(
+ choices=add_blank_choice(WeightUnitChoices),
+ required=False
+ )
class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 5728e7f2d..06d63af94 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -260,7 +260,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'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',
- 'outer_unit', 'comments', 'tags',
+ 'outer_unit', 'weight', 'weight_unit', 'comments', 'tags',
]
help_texts = {
'site': "The site at which the rack exists",
@@ -273,6 +273,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
'type': StaticSelect(),
'width': StaticSelect(),
'outer_unit': StaticSelect(),
+ 'weight_unit': StaticSelect(),
}
@@ -363,6 +364,7 @@ class DeviceTypeForm(NetBoxModelForm):
('Chassis', (
'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
)),
+ ('Attributes', ('weight', 'weight_unit')),
('Images', ('front_image', 'rear_image')),
)
@@ -370,16 +372,18 @@ class DeviceTypeForm(NetBoxModelForm):
model = DeviceType
fields = [
'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 = {
+ 'airflow': StaticSelect(),
'subdevice_role': StaticSelect(),
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
- })
+ }),
+ 'weight_unit': StaticSelect(),
}
@@ -391,16 +395,20 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = (
('Module Type', (
- 'manufacturer', 'model', 'part_number', 'tags',
+ 'manufacturer', 'model', 'part_number', 'tags', 'weight', 'weight_unit'
)),
)
class Meta:
model = ModuleType
fields = [
- 'manufacturer', 'model', 'part_number', 'comments', 'tags',
+ 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'comments', 'tags',
]
+ widgets = {
+ 'weight_unit': StaticSelect(),
+ }
+
class DeviceRoleForm(NetBoxModelForm):
slug = SlugField()
@@ -678,6 +686,7 @@ class ModuleForm(NetBoxModelForm):
super().__init__(*args, **kwargs)
if self.instance.pk:
+ self.fields['device'].disabled = True
self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True
self.fields['adopt_components'].initial = False
diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py
new file mode 100644
index 000000000..d6be138bc
--- /dev/null
+++ b/netbox/dcim/graphql/gfk_mixins.py
@@ -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
diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py
index d8488aa5f..133d6259f 100644
--- a/netbox/dcim/graphql/mixins.py
+++ b/netbox/dcim/graphql/mixins.py
@@ -1,5 +1,12 @@
+import graphene
+
+
class CabledObjectMixin:
+ link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
def resolve_cable_end(self, info):
# Handle empty values
return self.cable_end or None
+
+ def resolve_link_peers(self, info):
+ return self.link_peers
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 52a98278a..78cabbcd1 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -211,6 +211,9 @@ class DeviceTypeType(NetBoxObjectType):
def resolve_airflow(self, info):
return self.airflow or None
+ def resolve_weight_unit(self, info):
+ return self.weight_unit or None
+
class FrontPortType(ComponentObjectType, CabledObjectMixin):
@@ -328,6 +331,9 @@ class ModuleTypeType(NetBoxObjectType):
fields = '__all__'
filterset_class = filtersets.ModuleTypeFilterSet
+ def resolve_weight_unit(self, info):
+ return self.weight_unit or None
+
class PlatformType(OrganizationalObjectType):
@@ -416,6 +422,9 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
def resolve_outer_unit(self, info):
return self.outer_unit or None
+ def resolve_weight_unit(self, info):
+ return self.weight_unit or None
+
class RackReservationType(NetBoxObjectType):
diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py
index 374d3bf45..fca7d8eb9 100644
--- a/netbox/dcim/migrations/0001_squashed.py
+++ b/netbox/dcim/migrations/0001_squashed.py
@@ -1,6 +1,6 @@
import dcim.fields
import django.contrib.postgres.fields
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
@@ -28,7 +28,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('termination_a_id', models.PositiveIntegerField()),
('termination_b_id', models.PositiveIntegerField()),
@@ -60,7 +60,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -132,7 +132,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('local_context_data', models.JSONField(blank=True, null=True)),
('name', models.CharField(blank=True, max_length=64, null=True)),
@@ -155,7 +155,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -186,7 +186,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -203,7 +203,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('model', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@@ -224,7 +224,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -261,7 +261,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('label', models.CharField(blank=True, max_length=64)),
@@ -302,7 +302,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -326,7 +326,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('slug', models.SlugField(max_length=100)),
@@ -345,7 +345,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -360,7 +360,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -377,7 +377,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('_cable_peer_id', models.PositiveIntegerField(blank=True, null=True)),
('mark_connected', models.BooleanField(default=False)),
@@ -401,7 +401,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -438,7 +438,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
],
@@ -451,7 +451,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -490,7 +490,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -516,7 +516,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('units', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveSmallIntegerField(), size=None)),
('description', models.CharField(max_length=200)),
@@ -530,7 +530,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -546,7 +546,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -583,7 +583,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -602,7 +602,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -630,7 +630,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
@@ -649,7 +649,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('domain', models.CharField(blank=True, max_length=30)),
diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py
index 11324fc58..821cf6119 100644
--- a/netbox/dcim/migrations/0146_modules.py
+++ b/netbox/dcim/migrations/0146_modules.py
@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
@@ -107,7 +107,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('model', models.CharField(max_length=100)),
('part_number', models.CharField(blank=True, max_length=50)),
@@ -125,7 +125,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=64)),
('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
@@ -145,7 +145,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('local_context_data', models.JSONField(blank=True, null=True)),
('serial', models.CharField(blank=True, max_length=50)),
diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py
index f5e1f23f5..cbdd36c08 100644
--- a/netbox/dcim/migrations/0147_inventoryitemrole.py
+++ b/netbox/dcim/migrations/0147_inventoryitemrole.py
@@ -1,4 +1,4 @@
-import django.core.serializers.json
+from utilities.json import CustomFieldJSONEncoder
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('created', models.DateField(auto_now_add=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)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
diff --git a/netbox/dcim/migrations/0162_unique_constraints.py b/netbox/dcim/migrations/0162_unique_constraints.py
new file mode 100644
index 000000000..d52dbb6c9
--- /dev/null
+++ b/netbox/dcim/migrations/0162_unique_constraints.py
@@ -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.'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py
new file mode 100644
index 000000000..09bef5736
--- /dev/null
+++ b/netbox/dcim/migrations/0163_rack_devicetype_moduletype_weights.py
@@ -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),
+ ),
+ ]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index e05eb6d51..fad3e8bd6 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -269,7 +269,7 @@ class CableTermination(models.Model):
constraints = (
models.UniqueConstraint(
fields=('termination_type', 'termination_id'),
- name='dcim_cable_termination_unique_termination'
+ name='%(app_label)s_%(class)s_unique_termination'
),
)
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index b7079d375..15389a2c0 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -61,6 +61,13 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
class Meta:
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):
if self.label:
@@ -100,6 +107,17 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
class Meta:
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):
objectchange = super().to_objectchange(action)
@@ -145,13 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
component_model = ConsolePort
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -181,13 +192,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
component_model = ConsoleServerPort
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -229,13 +233,6 @@ class PowerPortTemplate(ModularComponentTemplateModel):
component_model = PowerPort
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -291,13 +288,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
component_model = PowerOutlet
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def clean(self):
super().clean()
@@ -372,13 +362,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
component_model = Interface
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -428,12 +411,20 @@ class FrontPortTemplate(ModularComponentTemplateModel):
component_model = FrontPort
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- ('rear_port', 'rear_port_position'),
+ class Meta(ModularComponentTemplateModel.Meta):
+ 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'
+ ),
+ models.UniqueConstraint(
+ fields=('rear_port', 'rear_port_position'),
+ name='%(app_label)s_%(class)s_unique_rear_port_position'
+ ),
)
def clean(self):
@@ -507,13 +498,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
component_model = RearPort
- class Meta:
- ordering = ('device_type', 'module_type', '_name')
- unique_together = (
- ('device_type', 'name'),
- ('module_type', 'name'),
- )
-
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -547,10 +531,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
component_model = ModuleBay
- class Meta:
- ordering = ('device_type', '_name')
- unique_together = ('device_type', 'name')
-
def instantiate(self, device):
return self.component_model(
device=device,
@@ -574,10 +554,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
"""
component_model = DeviceBay
- class Meta:
- ordering = ('device_type', '_name')
- unique_together = ('device_type', 'name')
-
def instantiate(self, device):
return self.component_model(
device=device,
@@ -653,7 +629,12 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
class Meta:
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):
parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 8f1285901..59d63ef7b 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -69,6 +69,13 @@ class ComponentModel(NetBoxModel):
class Meta:
abstract = True
+ ordering = ('device', '_name')
+ constraints = (
+ models.UniqueConstraint(
+ fields=('device', 'name'),
+ name='%(app_label)s_%(class)s_unique_device_name'
+ ),
+ )
def __str__(self):
if self.label:
@@ -99,7 +106,7 @@ class ModularComponentModel(ComponentModel):
object_id_field='component_id'
)
- class Meta:
+ class Meta(ComponentModel.Meta):
abstract = True
@@ -265,10 +272,6 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed')
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
@@ -292,10 +295,6 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
clone_fields = ('device', 'module', 'type', 'speed')
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
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')
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
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')
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
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',
)
- class Meta:
+ class Meta(ModularComponentModel.Meta):
ordering = ('device', CollateAsChar('_name'))
- unique_together = ('device', 'name')
def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk})
@@ -895,11 +885,16 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
clone_fields = ('device', 'type', 'color')
- class Meta:
- ordering = ('device', '_name')
- unique_together = (
- ('device', 'name'),
- ('rear_port', 'rear_port_position'),
+ class Meta(ModularComponentModel.Meta):
+ constraints = (
+ models.UniqueConstraint(
+ fields=('device', 'name'),
+ 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):
@@ -944,10 +939,6 @@ class RearPort(ModularComponentModel, CabledObjectModel):
)
clone_fields = ('device', 'type', 'color', 'positions')
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
@@ -955,12 +946,13 @@ class RearPort(ModularComponentModel, CabledObjectModel):
super().clean()
# Check that positions count is greater than or equal to the number of associated FrontPorts
- frontport_count = self.frontports.count()
- if self.positions < frontport_count:
- raise ValidationError({
- "positions": f"The number of positions cannot be less than the number of mapped front ports "
- f"({frontport_count})"
- })
+ if self.pk:
+ frontport_count = self.frontports.count()
+ if self.positions < frontport_count:
+ raise ValidationError({
+ "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',)
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
@@ -1001,10 +989,6 @@ class DeviceBay(ComponentModel):
clone_fields = ('device',)
- class Meta:
- ordering = ('device', '_name')
- unique_together = ('device', 'name')
-
def get_absolute_url(self):
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
@@ -1140,7 +1124,12 @@ class InventoryItem(MPTTModel, ComponentModel):
class Meta:
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):
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index ccf4613bf..d4646762f 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1,13 +1,15 @@
import decimal
-
import yaml
+from functools import cached_property
+
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError
+from django.db.models.functions import Lower
from django.urls import reverse
from django.utils.safestring import mark_safe
@@ -20,6 +22,7 @@ from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from .device_components import *
+from .mixins import WeightMixin
__all__ = (
@@ -70,7 +73,7 @@ class Manufacturer(OrganizationalModel):
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
well as high-level functional role(s).
@@ -138,15 +141,21 @@ class DeviceType(NetBoxModel):
)
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:
ordering = ['manufacturer', 'model']
- unique_together = [
- ['manufacturer', 'model'],
- ['manufacturer', 'slug'],
- ]
+ constraints = (
+ models.UniqueConstraint(
+ 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):
return self.model
@@ -268,7 +277,7 @@ class DeviceType(NetBoxModel):
if (
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
- ) and self.devicebaytemplates.count():
+ ) and self.pk and self.devicebaytemplates.count():
raise ValidationError({
'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
@@ -308,7 +317,7 @@ class DeviceType(NetBoxModel):
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
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'
)
- clone_fields = ('manufacturer',)
+ clone_fields = ('manufacturer', 'weight', 'weight_unit',)
class Meta:
ordering = ('manufacturer', 'model')
- unique_together = (
- ('manufacturer', 'model'),
+ constraints = (
+ models.UniqueConstraint(
+ fields=('manufacturer', 'model'),
+ name='%(app_label)s_%(class)s_unique_manufacturer_model'
+ ),
)
def __str__(self):
@@ -651,10 +663,25 @@ class Device(NetBoxModel, ConfigContextModel):
class Meta:
ordering = ('_name', 'pk') # Name may be null
- unique_together = (
- ('site', 'tenant', 'name'), # See validate_unique below
- ('rack', 'position', 'face'),
- ('virtual_chassis', 'vc_position'),
+ constraints = (
+ models.UniqueConstraint(
+ Lower('name'), 'site', 'tenant',
+ 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):
@@ -679,23 +706,6 @@ class Device(NetBoxModel, ConfigContextModel):
def get_absolute_url(self):
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):
super().clean()
@@ -938,6 +948,18 @@ class Device(NetBoxModel, ConfigContextModel):
def get_status_color(self):
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):
"""
@@ -987,6 +1009,14 @@ class Module(NetBoxModel, ConfigContextModel):
def get_absolute_url(self):
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):
is_new = self.pk is None
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
new file mode 100644
index 000000000..b5449332b
--- /dev/null
+++ b/netbox/dcim/models/mixins.py
@@ -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 = ''
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 83eead67f..39f0f37ef 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -50,7 +50,12 @@ class PowerPanel(NetBoxModel):
class Meta:
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):
return self.name
@@ -138,7 +143,12 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
class Meta:
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):
return self.name
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 20027675a..6da48b65c 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -1,14 +1,14 @@
import decimal
+from functools import cached_property
from django.apps import apps
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
-from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
-from django.db.models import Count, Sum
+from django.db.models import Count
from django.urls import reverse
from dcim.choices import *
@@ -18,8 +18,9 @@ from netbox.models import OrganizationalModel, NetBoxModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange
-from .device_components import PowerOutlet, PowerPort
-from .devices import Device
+from .device_components import PowerPort
+from .devices import Device, Module
+from .mixins import WeightMixin
from .power import PowerFeed
__all__ = (
@@ -63,7 +64,7 @@ class RackRole(OrganizationalModel):
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.
Each Rack is assigned to a Site and (optionally) a Location.
@@ -186,15 +187,21 @@ class Rack(NetBoxModel):
clone_fields = (
'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:
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
- ('location', 'name'),
- ('location', 'facility_id'),
+ models.UniqueConstraint(
+ 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):
@@ -449,6 +456,22 @@ class Rack(NetBoxModel):
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):
"""
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index f5c8e6d9d..9ddadace2 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -62,38 +62,26 @@ class Region(NestedGroupModel):
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
- name='dcim_region_parent_name'
+ name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('name',),
- name='dcim_region_name',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_name',
+ condition=Q(parent__isnull=True),
+ violation_error_message="A top-level region with this name already exists."
),
models.UniqueConstraint(
fields=('parent', 'slug'),
- name='dcim_region_parent_slug'
+ name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
- name='dcim_region_slug',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_slug',
+ 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):
return reverse('dcim:region', args=[self.pk])
@@ -148,38 +136,26 @@ class SiteGroup(NestedGroupModel):
constraints = (
models.UniqueConstraint(
fields=('parent', 'name'),
- name='dcim_sitegroup_parent_name'
+ name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('name',),
- name='dcim_sitegroup_name',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_name',
+ condition=Q(parent__isnull=True),
+ violation_error_message="A top-level site group with this name already exists."
),
models.UniqueConstraint(
fields=('parent', 'slug'),
- name='dcim_sitegroup_parent_slug'
+ name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('slug',),
- name='dcim_sitegroup_slug',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_slug',
+ 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):
return reverse('dcim:sitegroup', args=[self.pk])
@@ -379,38 +355,26 @@ class Location(NestedGroupModel):
constraints = (
models.UniqueConstraint(
fields=('site', 'parent', 'name'),
- name='dcim_location_parent_name'
+ name='%(app_label)s_%(class)s_parent_name'
),
models.UniqueConstraint(
fields=('site', 'name'),
- name='dcim_location_name',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_name',
+ condition=Q(parent__isnull=True),
+ violation_error_message="A location with this name already exists within the specified site."
),
models.UniqueConstraint(
fields=('site', 'parent', 'slug'),
- name='dcim_location_parent_slug'
+ name='%(app_label)s_%(class)s_parent_slug'
),
models.UniqueConstraint(
fields=('site', 'slug'),
- name='dcim_location_slug',
- condition=Q(parent=None)
+ name='%(app_label)s_%(class)s_slug',
+ 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
def get_prerequisite_models(cls):
return [Site, ]
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 3872bc4fe..9a847acc9 100644
--- a/netbox/dcim/svg/cables.py
+++ b/netbox/dcim/svg/cables.py
@@ -35,7 +35,7 @@ class Node(Hyperlink):
"""
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
diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py
index 573fc966c..6c57e6023 100644
--- a/netbox/dcim/svg/racks.py
+++ b/netbox/dcim/svg/racks.py
@@ -9,6 +9,7 @@ from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
+from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.http import urlencode
@@ -41,7 +42,7 @@ def get_device_description(device):
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
- device.device_type.u_height,
+ floatformat(device.device_type.u_height),
device.asset_tag or '',
device.serial or ''
)
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 3ed4d8c08..c48e93ca7 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -5,7 +5,7 @@ from dcim.models import (
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
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__ = (
'ConsolePortTemplateTable',
@@ -85,12 +85,22 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn(
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):
model = DeviceType
fields = (
'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 = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py
index e40d7bd80..b644e6ba6 100644
--- a/netbox/dcim/tables/modules.py
+++ b/netbox/dcim/tables/modules.py
@@ -2,6 +2,7 @@ import django_tables2 as tables
from dcim.models import Module, ModuleType
from netbox.tables import NetBoxTable, columns
+from .template_code import DEVICE_WEIGHT
__all__ = (
'ModuleTable',
@@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
+ weight = columns.TemplateColumn(
+ template_code=DEVICE_WEIGHT,
+ order_by=('_abs_weight', 'weight_unit')
+ )
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
- 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags',
+ 'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'comments', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index 39553bac0..ffca07145 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
+from .template_code import DEVICE_WEIGHT
__all__ = (
'RackTable',
@@ -82,13 +83,17 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth'
)
+ weight = columns.TemplateColumn(
+ template_code=DEVICE_WEIGHT,
+ order_by=('_abs_weight', 'weight_unit')
+ )
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
- 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag',
- 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
- 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
+ 'asset_tag', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'weight', 'comments',
+ 'device_count', 'get_utilization', 'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index dfc77b854..9b8fb8fd6 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -15,6 +15,11 @@ CABLE_LENGTH = """
{% 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 = """
{{ record.name|default:'Unnamed device' }}
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index feef4e90c..d4922fb1d 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
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 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 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 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, 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, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
Rack.objects.bulk_create(racks)
@@ -517,6 +517,14 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
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):
queryset = RackReservation.objects.all()
@@ -688,9 +696,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
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[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[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[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, 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, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
DeviceType.objects.bulk_create(device_types)
@@ -839,6 +847,14 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'inventory_items': 'false'}
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):
queryset = ModuleType.objects.all()
@@ -855,9 +871,9 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
module_types = (
- ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'),
- ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'),
- ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'),
+ 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', weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
+ 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)
@@ -943,6 +959,14 @@ class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'pass_through_ports': 'false'}
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):
queryset = ConsolePortTemplate.objects.all()
@@ -1611,6 +1635,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_name(self):
params = {'name': ['Device 1', 'Device 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):
params = {'asset_tag': ['1001', '1002']}
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 0e02b0de5..460a5e252 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -384,7 +384,7 @@ class DeviceTestCase(TestCase):
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
- name=''
+ name=None
)
device1.save()
@@ -392,12 +392,33 @@ class DeviceTestCase(TestCase):
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
- name=''
+ name=None
)
device2.full_clean()
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):
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 50b36e36d..db3495521 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1778,10 +1778,12 @@ class ModuleTestCase(
ModuleBay(device=devices[0], name='Module Bay 2'),
ModuleBay(device=devices[0], name='Module Bay 3'),
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 2'),
ModuleBay(device=devices[1], name='Module Bay 3'),
ModuleBay(device=devices[1], name='Module Bay 4'),
+ ModuleBay(device=devices[1], name='Module Bay 5'),
)
ModuleBay.objects.bulk_create(module_bays)
@@ -1795,7 +1797,7 @@ class ModuleTestCase(
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
- 'device': devices[1].pk,
+ 'device': devices[0].pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'serial': 'A',
@@ -1867,7 +1869,6 @@ class ModuleTestCase(
self.assertIsNone(interface.module)
# Create a module with adopted components
- form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
form_data['module_type'] = module_type
form_data['replicate_components'] = False
form_data['adopt_components'] = True
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index c11a92a99..ecd2d46c5 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -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 .models import *
app_name = 'dcim'
urlpatterns = [
@@ -13,10 +12,7 @@ urlpatterns = [
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
- path('regions//', views.RegionView.as_view(), name='region'),
- path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'),
- path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'),
- path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
+ path('regions//', include(get_model_urls('dcim', 'region'))),
# Site groups
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/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
- path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'),
- path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
- path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
- path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
+ path('site-groups//', include(get_model_urls('dcim', 'sitegroup'))),
# Sites
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/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
- path('sites//', views.SiteView.as_view(), name='site'),
- path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'),
- path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'),
- path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
- path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
+ path('sites//', include(get_model_urls('dcim', 'site'))),
# Locations
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/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
- path('locations//', views.LocationView.as_view(), name='location'),
- path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'),
- path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'),
- path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
+ path('locations//', include(get_model_urls('dcim', 'location'))),
# Rack roles
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/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
- path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'),
- path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
- path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
- path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
+ path('rack-roles//', include(get_model_urls('dcim', 'rackrole'))),
# Rack reservations
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/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
- path('rack-reservations//', views.RackReservationView.as_view(), name='rackreservation'),
- path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
- path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
- path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
- path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}),
+ path('rack-reservations//', include(get_model_urls('dcim', 'rackreservation'))),
# Racks
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/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
- path('racks//', views.RackView.as_view(), name='rack'),
- path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'),
- path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'),
- path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
- path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
+ path('racks//', include(get_model_urls('dcim', 'rack'))),
# Manufacturers
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/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
- path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'),
- path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
- path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
- path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
+ path('manufacturers//', include(get_model_urls('dcim', 'manufacturer'))),
# Device types
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/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
- path('device-types//', views.DeviceTypeView.as_view(), name='devicetype'),
- path('device-types//console-ports/', views.DeviceTypeConsolePortsView.as_view(), name='devicetype_consoleports'),
- path('device-types//console-server-ports/', views.DeviceTypeConsoleServerPortsView.as_view(), name='devicetype_consoleserverports'),
- path('device-types//power-ports/', views.DeviceTypePowerPortsView.as_view(), name='devicetype_powerports'),
- path('device-types//power-outlets/', views.DeviceTypePowerOutletsView.as_view(), name='devicetype_poweroutlets'),
- path('device-types//interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
- path('device-types//front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
- path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
- path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
- path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
- path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'),
- path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
- path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
- path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
- path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
+ path('device-types//', include(get_model_urls('dcim', 'devicetype'))),
# Module types
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/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'),
path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'),
- path('module-types//', views.ModuleTypeView.as_view(), name='moduletype'),
- path('module-types//console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'),
- path('module-types//console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'),
- path('module-types//power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'),
- path('module-types//power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'),
- path('module-types//interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'),
- path('module-types//front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'),
- path('module-types//rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'),
- path('module-types//edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'),
- path('module-types//delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
- path('module-types//changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}),
- path('module-types//journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}),
+ path('module-types//', include(get_model_urls('dcim', 'moduletype'))),
# Console port templates
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/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//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
- path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
+ path('console-port-templates//', include(get_model_urls('dcim', 'consoleporttemplate'))),
# Console server port templates
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/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//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
- path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
+ path('console-server-port-templates//', include(get_model_urls('dcim', 'consoleserverporttemplate'))),
# Power port templates
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/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//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
- path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
+ path('power-port-templates//', include(get_model_urls('dcim', 'powerporttemplate'))),
# Power outlet templates
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/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//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
- path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
+ path('power-outlet-templates//', include(get_model_urls('dcim', 'poweroutlettemplate'))),
# Interface templates
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/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
- path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
- path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
+ path('interface-templates//', include(get_model_urls('dcim', 'interfacetemplate'))),
# Front port templates
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/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//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
- path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
+ path('front-port-templates//', include(get_model_urls('dcim', 'frontporttemplate'))),
# Rear port templates
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/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//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
- path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
+ path('rear-port-templates//', include(get_model_urls('dcim', 'rearporttemplate'))),
# Device bay templates
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/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//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
- path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
+ path('device-bay-templates//', include(get_model_urls('dcim', 'devicebaytemplate'))),
# Module bay templates
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/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//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'),
- path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'),
+ path('module-bay-templates//', include(get_model_urls('dcim', 'modulebaytemplate'))),
# Inventory item templates
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/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//edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'),
- path('inventory-item-templates//delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'),
+ path('inventory-item-templates//', include(get_model_urls('dcim', 'inventoryitemtemplate'))),
# Device roles
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/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
- path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'),
- path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
- path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
- path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
+ path('device-roles//', include(get_model_urls('dcim', 'devicerole'))),
# Platforms
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/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
- path('platforms//', views.PlatformView.as_view(), name='platform'),
- path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'),
- path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
- path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
+ path('platforms//', include(get_model_urls('dcim', 'platform'))),
# Devices
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/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
- path('devices//', views.DeviceView.as_view(), name='device'),
- path('devices//edit/', views.DeviceEditView.as_view(), name='device_edit'),
- path('devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
- path('devices//console-ports/', views.DeviceConsolePortsView.as_view(), name='device_consoleports'),
- path('devices//console-server-ports/', views.DeviceConsoleServerPortsView.as_view(), name='device_consoleserverports'),
- path('devices//power-ports/', views.DevicePowerPortsView.as_view(), name='device_powerports'),
- path('devices//power-outlets/', views.DevicePowerOutletsView.as_view(), name='device_poweroutlets'),
- path('devices//interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
- path('devices//front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
- path('devices//rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
- path('devices//module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'),
- path('devices//device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
- path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
- path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
- path('devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
- path('devices//journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
- path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'),
- path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
- path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'),
+ path('devices//', include(get_model_urls('dcim', 'device'))),
# Modules
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/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'),
path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'),
- path('modules//', views.ModuleView.as_view(), name='module'),
- path('modules//edit/', views.ModuleEditView.as_view(), name='module_edit'),
- path('modules//delete/', views.ModuleDeleteView.as_view(), name='module_delete'),
- path('modules//changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}),
- path('modules//journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}),
+ path('modules//', include(get_model_urls('dcim', 'module'))),
# Console ports
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/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
- path('console-ports//', views.ConsolePortView.as_view(), name='consoleport'),
- path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
- path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
- path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
- path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
+ path('console-ports//', include(get_model_urls('dcim', 'consoleport'))),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# 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/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//', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
- path('console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
- path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
- path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
- path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
+ path('console-server-ports//', include(get_model_urls('dcim', 'consoleserverport'))),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@@ -320,11 +221,7 @@ urlpatterns = [
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/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
- path('power-ports//', views.PowerPortView.as_view(), name='powerport'),
- path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
- path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
- path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
- path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
+ path('power-ports//', include(get_model_urls('dcim', 'powerport'))),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@@ -335,11 +232,7 @@ urlpatterns = [
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/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
- path('power-outlets//', views.PowerOutletView.as_view(), name='poweroutlet'),
- path('power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
- path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
- path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
- path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
+ path('power-outlets//', include(get_model_urls('dcim', 'poweroutlet'))),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@@ -350,11 +243,7 @@ urlpatterns = [
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
- path('interfaces//', views.InterfaceView.as_view(), name='interface'),
- path('interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
- path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
- path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
- path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
+ path('interfaces//', include(get_model_urls('dcim', 'interface'))),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@@ -365,11 +254,7 @@ urlpatterns = [
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/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
- path('front-ports//', views.FrontPortView.as_view(), name='frontport'),
- path('front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
- path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
- path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
- path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
+ path('front-ports//', include(get_model_urls('dcim', 'frontport'))),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@@ -380,11 +265,7 @@ urlpatterns = [
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/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
- path('rear-ports//', views.RearPortView.as_view(), name='rearport'),
- path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
- path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
- path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
- path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
+ path('rear-ports//', include(get_model_urls('dcim', 'rearport'))),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Module bays
@@ -394,10 +275,7 @@ urlpatterns = [
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/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'),
- path('module-bays//', views.ModuleBayView.as_view(), name='modulebay'),
- path('module-bays//edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'),
- path('module-bays//delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'),
- path('module-bays//changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}),
+ path('module-bays//', include(get_model_urls('dcim', 'modulebay'))),
path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'),
# Device bays
@@ -407,12 +285,7 @@ urlpatterns = [
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/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
- path('device-bays//', views.DeviceBayView.as_view(), name='devicebay'),
- path('device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
- path('device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
- path('device-bays//changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
- path('device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
- path('device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
+ path('device-bays//', include(get_model_urls('dcim', 'devicebay'))),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
# Inventory items
@@ -422,22 +295,16 @@ urlpatterns = [
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/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
- path('inventory-items//', views.InventoryItemView.as_view(), name='inventoryitem'),
- path('inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
- path('inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
- path('inventory-items//changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
+ path('inventory-items//', include(get_model_urls('dcim', '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/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'),
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/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'),
- path('inventory-item-roles//', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'),
- path('inventory-item-roles//edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'),
- path('inventory-item-roles//delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'),
- path('inventory-item-roles//changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}),
+ path('inventory-item-roles//', include(get_model_urls('dcim', 'inventoryitemrole'))),
# Cables
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/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
- path('cables//', views.CableView.as_view(), name='cable'),
- path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'),
- path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'),
- path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
- path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}),
+ path('cables//', include(get_model_urls('dcim', 'cable'))),
# Console/power/interface connections (read-only)
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/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
- path('virtual-chassis//', views.VirtualChassisView.as_view(), name='virtualchassis'),
- path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
- path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
- path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
- path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}),
- path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
+ path('virtual-chassis//', include(get_model_urls('dcim', 'virtualchassis'))),
path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels
@@ -476,11 +334,7 @@ urlpatterns = [
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/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
- path('power-panels//', views.PowerPanelView.as_view(), name='powerpanel'),
- path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
- path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
- path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
- path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}),
+ path('power-panels//', include(get_model_urls('dcim', 'powerpanel'))),
# Power feeds
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/disconnect/', views.PowerFeedBulkDisconnectView.as_view(), name='powerfeed_bulk_disconnect'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
- path('power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'),
- path('power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
- path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
- path('power-feeds/