diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 08bccc551..c5b45c31c 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.2
+ placeholder: v3.4.2
validations:
required: true
- type: dropdown
@@ -25,6 +25,7 @@ body:
- "3.8"
- "3.9"
- "3.10"
+ - "3.11"
validations:
required: true
- type: textarea
diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml
index 0f87115fc..cb097d579 100644
--- a/.github/ISSUE_TEMPLATE/documentation_change.yaml
+++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml
@@ -19,11 +19,15 @@ body:
label: Area
description: To what section of the documentation does this change primarily pertain?
options:
- - Installation instructions
- - Configuration parameters
- - Functionality/features
- - REST API
- - Administration/development
+ - Features
+ - Installation/upgrade
+ - Getting started
+ - Configuration
+ - Customization
+ - Integrations/API
+ - Plugins
+ - Administration
+ - Development
- Other
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 4dbf51f2c..54a7735c5 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.2
+ placeholder: v3.4.2
validations:
required: true
- type: dropdown
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 33134cb45..0bbbe90c7 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,13 +1,14 @@
### Fixes: #1234
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 67f5028cd..1d9692194 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
@@ -7,7 +9,7 @@ jobs:
NETBOX_CONFIGURATION: netbox.configuration_testing
strategy:
matrix:
- python-version: ['3.8', '3.9', '3.10']
+ python-version: ['3.8', '3.9', '3.10', '3.11']
node-version: ['14.x']
services:
redis:
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index b6073a71b..6019cef5d 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -4,18 +4,18 @@ name: 'Lock threads'
on:
schedule:
- cron: '0 3 * * *'
+ workflow_dispatch:
+
+permissions:
+ issues: write
+ pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
steps:
- - uses: dessant/lock-threads@v2
+ - uses: dessant/lock-threads@v3
with:
- github-token: ${{ github.token }}
- issue-lock-inactive-days: '90'
- issue-exclude-created-before: ''
- issue-exclude-labels: ''
- issue-lock-labels: ''
- issue-lock-comment: ''
+ issue-inactive-days: 90
+ pr-inactive-days: 30
issue-lock-reason: 'resolved'
- process-only: 'issues'
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..3e4811ece 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
@@ -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/data-validation.md b/docs/configuration/data-validation.md
index e4eb4baff..9ff71758f 100644
--- a/docs/configuration/data-validation.md
+++ b/docs/configuration/data-validation.md
@@ -58,9 +58,11 @@ The following model fields support configurable choices:
* `circuits.Circuit.status`
* `dcim.Device.status`
* `dcim.Location.status`
+* `dcim.Module.status`
* `dcim.PowerFeed.status`
* `dcim.Rack.status`
* `dcim.Site.status`
+* `dcim.VirtualDeviceContext.status`
* `extras.JournalEntry.kind`
* `ipam.IPAddress.status`
* `ipam.IPRange.status`
@@ -68,6 +70,7 @@ The following model fields support configurable choices:
* `ipam.VLAN.status`
* `virtualization.Cluster.status`
* `virtualization.VirtualMachine.status`
+* `wireless.WirelessLAN.status`
The following colors are supported:
diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md
index 614e90eac..4eb090554 100644
--- a/docs/configuration/miscellaneous.md
+++ b/docs/configuration/miscellaneous.md
@@ -141,6 +141,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
+## QUEUE_MAPPINGS
+
+Allows changing which queues are used internally for background tasks.
+
+```python
+QUEUE_MAPPINGS = {
+ 'webhook': 'low',
+ 'report': 'high',
+ 'script': 'high',
+}
+```
+
+If no queue is defined the queue named `default` will be used.
+
+---
+
## RELEASE_CHECK_URL
Default: None (disabled)
diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md
index a62d14fef..a71a1b410 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
@@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
+* `USERNAME` - Redis username (if set)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
@@ -75,6 +76,7 @@ REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
+ 'USERNAME': 'netbox'
'PASSWORD': 'foobar',
'DATABASE': 0,
'SSL': False,
@@ -82,6 +84,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
+ 'USERNAME': ''
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,
diff --git a/docs/configuration/security.md b/docs/configuration/security.md
index 6aa363b1a..ae023b4d0 100644
--- a/docs/configuration/security.md
+++ b/docs/configuration/security.md
@@ -1,5 +1,13 @@
# Security & Authentication Parameters
+## ALLOW_TOKEN_RETRIEVAL
+
+Default: True
+
+If disabled, the values of API tokens will not be displayed after each token's initial creation. A user **must** record the value of a token immediately upon its creation, or it will be lost. Note that this affects _all_ users, regardless of assigned permissions.
+
+---
+
## ALLOWED_URL_SCHEMES
!!! tip "Dynamic Configuration Parameter"
@@ -129,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
+## LOGOUT_REDIRECT_URL
+
+Default: `'home'`
+
+The view name or URL to which a user is redirected after logging out.
+
+---
+
## SESSION_COOKIE_NAME
Default: `sessionid`
diff --git a/docs/configuration/system.md b/docs/configuration/system.md
index 21607e566..7061274f1 100644
--- a/docs/configuration/system.md
+++ b/docs/configuration/system.md
@@ -12,6 +12,17 @@ BASE_PATH = 'netbox/'
---
+## DEFAULT_LANGUAGE
+
+Default: `en-us` (US English)
+
+Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
+
+!!! note
+ Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
+
+---
+
## DOCS_ROOT
Default: `$INSTALL_ROOT/docs/`
@@ -54,11 +65,19 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
+## ENABLE_LOCALIZATION
+
+Default: False
+
+Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
+
+---
+
## HTTP_PROXIES
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 = {
@@ -157,6 +176,14 @@ The file path to the location where [custom scripts](../customization/custom-scr
---
+## SEARCH_BACKEND
+
+Default: `'netbox.search.backends.CachedValueSearchBackend'`
+
+The dotted path to the desired search backend class. `CachedValueSearchBackend` is currently the only search backend provided in NetBox, however this setting can be used to enable a custom backend.
+
+---
+
## STORAGE_BACKEND
Default: None (local storage)
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..456bcf472 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
@@ -254,7 +267,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
### Via the Web UI
-Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button.
+Custom scripts can be run via the web UI by navigating to the script, completing any required form data, and clicking the "run script" button. It is possible to schedule a script to be executed at specified time in the future. A scheduled script can be canceled by deleting the associated job result object.
### Via the API
@@ -269,6 +282,8 @@ http://netbox/api/extras/scripts/example.MyReport/ \
--data '{"data": {"foo": "somevalue", "bar": 123}, "commit": true}'
```
+Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
+
### Via the CLI
Scripts can be run on the CLI by invoking the management command:
diff --git a/docs/customization/reports.md b/docs/customization/reports.md
index 150c32f40..b83c4a177 100644
--- a/docs/customization/reports.md
+++ b/docs/customization/reports.md
@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined.
active = DeviceStatusChoices.STATUS_ACTIVE
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
- if console_port.connected_endpoint is None:
+ if not console_port.connected_endpoints:
self.log_failure(
console_port.device,
"No console connection defined for {}".format(console_port.name)
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
- if power_port.connected_endpoint is not None:
+ if power_port.connected_endpoints:
connected_ports += 1
if not power_port.path.is_active:
self.log_warning(
@@ -136,7 +136,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
### Via the Web UI
-Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view.
+Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object.
### Via the API
@@ -152,6 +152,8 @@ Our example report above would be called as:
POST /api/extras/reports/devices.DeviceConnectionsReport/run/
```
+Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
+
### Via the CLI
Reports can be run on the CLI by invoking the management command:
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/development/extending-models.md b/docs/development/extending-models.md
index ad8fe5024..b7fd5e1e5 100644
--- a/docs/development/extending-models.md
+++ b/docs/development/extending-models.md
@@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
-## 8. Update the UI templates
+## 8. Update the SearchIndex
+
+Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
+
+## 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
-## 9. Create/extend test cases
+## 10. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
@@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
-## 10. Update the model's documentation
+## 11. Update the model's documentation
Each model has a dedicated page in the documentation, at `models//.md`. Update this file to include any relevant information about the new field.
diff --git a/docs/development/models.md b/docs/development/models.md
index 01070fa3d..af11617c8 100644
--- a/docs/development/models.md
+++ b/docs/development/models.md
@@ -40,6 +40,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [dcim.RackReservation](../models/dcim/rackreservation.md)
* [dcim.Site](../models/dcim/site.md)
* [dcim.VirtualChassis](../models/dcim/virtualchassis.md)
+* [dcim.VirtualDeviceContext](../models/dcim/virtualdevicecontext.md)
* [ipam.Aggregate](../models/ipam/aggregate.md)
* [ipam.ASN](../models/ipam/asn.md)
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
diff --git a/docs/development/search.md b/docs/development/search.md
new file mode 100644
index 000000000..02bcaa898
--- /dev/null
+++ b/docs/development/search.md
@@ -0,0 +1,37 @@
+# Search
+
+NetBox v3.4 introduced a new global search mechanism, which employs the `extras.CachedValue` model to store discrete field values from many models in a single table.
+
+## SearchIndex
+
+To enable search support for a model, declare and register a subclass of `netbox.search.SearchIndex` for it. Typically, this will be done within an app's `search.py` module.
+
+```python
+from netbox.search import SearchIndex, register_search
+
+@register_search
+class MyModelIndex(SearchIndex):
+ model = MyModel
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+```
+
+A SearchIndex subclass defines both its model and a list of two-tuples specifying which model fields to be indexed and the weight (precedence) associated with each. Guidance on weight assignment for fields is provided below.
+
+### Field Weight Guidance
+
+| Weight | Field Role | Examples |
+|--------|--------------------------------------------------|----------------------------------------------------|
+| 50 | Unique serialized attribute | Device.asset_tag |
+| 60 | Unique serialized attribute (per related object) | Device.serial |
+| 100 | Primary human identifier | Device.name, Circuit.cid, Cable.label |
+| 110 | Slug | Site.slug |
+| 200 | Secondary identifier | Provider.account, DeviceType.part_number |
+| 300 | Highly unique descriptive attribute | CircuitTermination.xconnect_id, IPAddress.dns_name |
+| 500 | Description | Site.description |
+| 1000 | Custom field default | - |
+| 2000 | Other discrete attribute | CircuitTermination.port_speed |
+| 5000 | Comment field | Site.comments |
diff --git a/docs/features/customization.md b/docs/features/customization.md
index 813914ae2..abce4bcba 100644
--- a/docs/features/customization.md
+++ b/docs/features/customization.md
@@ -71,13 +71,13 @@ To learn more about this feature, check out the [export template documentation](
NetBox administrators can install custom Python scripts, known as _reports_, which run within NetBox and can be executed and analyzed within the NetBox UI. Reports are a great way to evaluate NetBox objects against a set of arbitrary rules. For example, you could write a report to check that every router has a loopback interface with an IP address assigned, or that every site has a minimum set of VLANs defined.
-When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command).
+When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). They can be run immediately or scheduled to run at a future time.
To learn more about this feature, check out the [documentation for reports](../customization/reports.md).
## Custom Scripts
-Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems.
+Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. As with reports, they can be run via the UI, REST API, or CLI, and be scheduled to execute at a future time.
The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model.
diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md
index bec3e56de..03f386977 100644
--- a/docs/features/devices-cabling.md
+++ b/docs/features/devices-cabling.md
@@ -65,6 +65,10 @@ Each device can have an operational status, functional role, and software platfo
Sometimes it is necessary to model a set of physical devices as sharing a single management plane. Perhaps the most common example of such a scenario is stackable switches. These can be modeled as virtual chassis in NetBox, with one device acting as the chassis master and the rest as members. All components of member devices will appear on the master.
+### Virtual Device Contexts
+
+A virtual device context (VDC) is a logical partition within a device. Each VDC operates autonomously but shares a common pool of resources. Each interface can be assigned to one or more VDCs on its device.
+
## Module Types & Modules
Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays.
diff --git a/docs/features/journaling.md b/docs/features/journaling.md
index ce126bf27..8aebdb446 100644
--- a/docs/features/journaling.md
+++ b/docs/features/journaling.md
@@ -1,5 +1,5 @@
# Journaling
-All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object.
+All primary and organizational models in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object.
Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created.
diff --git a/docs/features/search.md b/docs/features/search.md
new file mode 100644
index 000000000..07394af97
--- /dev/null
+++ b/docs/features/search.md
@@ -0,0 +1,27 @@
+# Search
+
+## Global Search
+
+NetBox includes a powerful global search engine, providing a single convenient interface to search across its complex data model. Relevant fields on each model are indexed according to their precedence, so that the most relevant results are returned first. When objects are created or modified, the search index is updated immediately, ensuring real-time accuracy.
+
+When entering a search query, the user can choose a specific lookup type: exact match, partial match, etc. When a partial match is found, the matching portion of the applicable field value is included with each result so that the user can easily determine its relevance.
+
+Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
+
+## Saved Filters
+
+Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.
+
+For example, suppose you often need to locate all planned devices of a certain type within a region. The applicable filters can be applied and then saved as custom named filter for reuse, such that
+
+```
+?status=planned&device_type_id=78®ion_id=12
+```
+
+becomes
+
+```
+?filter=my-custom-filter
+```
+
+These saved filters can be used both within the UI and for API queries.
diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md
index bb0e8e17f..9a2386d71 100644
--- a/docs/getting-started/populating-data.md
+++ b/docs/getting-started/populating-data.md
@@ -20,12 +20,14 @@ To create a new object in NetBox, find the object type in the navigation menu an
## Bulk Import (CSV/YAML)
-NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file.
+NetBox supports the bulk import of new objects, and updating of existing objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file.
When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.)
+If an "id" field is added the data will be used to update existing records instead of importing new objects.
+
Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components.
## Scripting
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/3-netbox.md b/docs/installation/3-netbox.md
index f42e28deb..68a582e7f 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo
Begin by installing all system packages required by NetBox and its dependencies.
!!! warning "Python 3.8 or later required"
- NetBox requires Python 3.8, 3.9, or 3.10.
+ NetBox requires Python 3.8, 3.9, 3.10 or 3.11.
=== "Ubuntu"
@@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight
-sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
+sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
```
@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
+!!! warning
+ If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
+
```no-highlight
sudo /opt/netbox/upgrade.sh
```
diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md
index 163ace70d..ffba6889b 100644
--- a/docs/installation/6-ldap.md
+++ b/docs/installation/6-ldap.md
@@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
### General Server Configuration
!!! info
- When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
+ When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
```python
import ldap
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
LDAP_IGNORE_CERT_ERRORS = True
+
+# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
+# Note that this is a NetBox-specific setting which sets:
+# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
+LDAP_CA_CERT_DIR = '/etc/ssl/certs'
+
+# Include this setting if you want to validate the LDAP server certificates against your own CA.
+# Note that this is a NetBox-specific setting which sets:
+# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
+LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
```
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.
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/integrations/graphql-api.md b/docs/integrations/graphql-api.md
index 4fc6d2dd8..a078bb82a 100644
--- a/docs/integrations/graphql-api.md
+++ b/docs/integrations/graphql-api.md
@@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type:
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
-For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).
+For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/).
## Filtering
@@ -56,6 +56,47 @@ The GraphQL API employs the same filtering logic as the UI and REST API. Filters
```
{"query": "query {site_list(region:\"north-carolina\", status:\"active\") {name}}"}
```
+In addition, filtering can be done on list of related objects as shown in the following query:
+
+```
+{
+ device_list {
+ id
+ name
+ interfaces(enabled: true) {
+ name
+ }
+ }
+}
+```
+
+## Multiple Return Types
+
+Certain queries can return multiple types of objects, for example cable terminations can return circuit terminations, console ports and many others. These can be queried using [inline fragments](https://graphql.org/learn/schema/#union-types) as shown below:
+
+```
+{
+ cable_list {
+ id
+ a_terminations {
+ ... on CircuitTerminationType {
+ id
+ class_type
+ }
+ ... on ConsolePortType {
+ id
+ class_type
+ }
+ ... on ConsoleServerPortType {
+ id
+ class_type
+ }
+ }
+ }
+}
+
+```
+The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
## Authentication
diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md
index 3a5aed055..6f54a8cb0 100644
--- a/docs/integrations/rest-api.md
+++ b/docs/integrations/rest-api.md
@@ -579,6 +579,9 @@ By default, a token can be used to perform all actions via the API that a user w
Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
+!!! warning "Restricting Token Retrieval"
+ The ability to retrieve the key value of a previously-created API token can be restricted by disabling the [`ALLOW_TOKEN_RETRIEVAL`](../configuration/security.md#allow_token_retrieval) configuration parameter.
+
#### Client IP Restriction
!!! note
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/module.md b/docs/models/dcim/module.md
index c90430faa..060c2b094 100644
--- a/docs/models/dcim/module.md
+++ b/docs/models/dcim/module.md
@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
+### Status
+
+The module's operational status.
+
+!!! tip
+ Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
### Serial Number
The unique physical serial number assigned to this module by its manufacturer.
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..b603e7a0b 100644
--- a/docs/models/dcim/rack.md
+++ b/docs/models/dcim/rack.md
@@ -65,6 +65,18 @@ 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.
+### Mounting Depth
+
+The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
+
+### Weight
+
+The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
+
+### Maximum Weight
+
+The maximum total weight capacity for all installed devices, inclusive of the rack itself.
+
### Descending Units
-If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)
+If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md
index c74c209e1..2e35ab11f 100644
--- a/docs/models/dcim/site.md
+++ b/docs/models/dcim/site.md
@@ -33,7 +33,7 @@ Each site can have multiple [AS numbers](../ipam/asn.md) assigned to it.
### Time Zone
-The site's local time zone. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.)
+The site's local time zone. (Time zones are provided by the [zoneinfo](https://docs.python.org/3/library/zoneinfo.html) library.)
### Physical Address
diff --git a/docs/models/dcim/virtualdevicecontext.md b/docs/models/dcim/virtualdevicecontext.md
new file mode 100644
index 000000000..fe2e61356
--- /dev/null
+++ b/docs/models/dcim/virtualdevicecontext.md
@@ -0,0 +1,33 @@
+# Virtual Device Context
+
+A virtual device context (VDC) represents a logical partition within a physical device, to which interfaces from the parent device can be allocated. Each VDC effectively provides an isolated control plane, but relies on shared resources of the parent device. A VDC is somewhat similar to a virtual machine in that it effects isolation between various components, but stops short of delivering a fully virtualized environment.
+
+Each VDC must be assigned to a device upon creation, after which interfaces belonging to that device can be assigned to one or more of its VDCs. A VDC can have any number of interfaces assigned to it, and an interface can belong to any number of VDCs.
+
+!!! info "A VDC by Any Other Name"
+ Network vendors use differing names for this concept. Cisco uses the term VDC, whereas Juniper refers to it as a _Virtual Routing Instance_, and Fortinet uses _Virtual Domain_, for instance. While there may be some nuance among the vendors' unique implementations, the general concept remains the same for each.
+
+## Fields
+
+### Device
+
+The device to which this VDC belongs.
+
+### Name
+
+The VDC's configured name. Must be unique to the assigned device.
+
+### Status
+
+The operational status of the VDC.
+
+### Identifier
+
+A vendor-prescribed unique identifier for the VDC (optional). Must be unique to the assigned device if defined.
+
+### Primary IPv4 & IPv6 Addresses
+
+Each VDC may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.
+
+!!! tip
+ NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
diff --git a/docs/models/extras/branch.md b/docs/models/extras/branch.md
new file mode 100644
index 000000000..fd7922b7e
--- /dev/null
+++ b/docs/models/extras/branch.md
@@ -0,0 +1,13 @@
+# Branches
+
+A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
+
+## Fields
+
+### Name
+
+The branch's name.
+
+### User
+
+The user to which the branch belongs (optional).
diff --git a/docs/models/extras/savedfilter.md b/docs/models/extras/savedfilter.md
new file mode 100644
index 000000000..68278403e
--- /dev/null
+++ b/docs/models/extras/savedfilter.md
@@ -0,0 +1,47 @@
+# Saved Filters
+
+When filtering lists of objects in NetBox, users can save applied filters for future use. This is handy for complex filter strategies involving multiple discrete filters. For example, you might want to find all planned devices within a region that have a specific platform. Once you've applied the desired filters to the object list, simply create a saved filter with name and optional description. This filter can then be applied directly for future queries via both the UI and REST API.
+
+## Fields
+
+### Name
+
+The filter's human-friendly name.
+
+### Slug
+
+The unique identifier by which this filter will be referenced during application (e.g. `?filter=my-slug`).
+
+### User
+
+The user to which this filter belongs. The current user will be assigned automatically when creating saved filters via the UI, and cannot be changed.
+
+### Weight
+
+A numeric weight used to override alphabetic ordering of filters by name. Saved filters with a lower weight will be listed before those with a higher weight.
+
+### Enabled
+
+Determines whether this filter can be used. Disabled filters will not appear as options in the UI, however they will be included in API results.
+
+### Shared
+
+Determines whether this filter is intended for use by all users or only its owner. Note that disabling this field does **not** hide the filter from other users; it is merely excluded from the list of available filters in UI object list views.
+
+### Parameters
+
+The query parameters to apply when the filter is active. These must be specified as JSON data. For example, the URL query string
+
+```
+?status=active®ion_id=51&tag=alpha&tag=bravo
+```
+
+is represented in JSON as
+
+```json
+{
+ 'tag': ['alpha', 'bravo'],
+ 'status': 'active',
+ 'region_id': 51
+}
+```
diff --git a/docs/models/extras/stagedchange.md b/docs/models/extras/stagedchange.md
new file mode 100644
index 000000000..feda2fee6
--- /dev/null
+++ b/docs/models/extras/stagedchange.md
@@ -0,0 +1,26 @@
+# Staged Changes
+
+A staged change represents the creation of a new object or the modification or deletion of an existing object to be performed at some future point. Each change must be assigned to a [branch](./branch.md).
+
+Changes can be applied individually via the `apply()` method, however it is recommended to apply changes in bulk using the parent branch's `commit()` method.
+
+## Fields
+
+!!! warning
+ Staged changes are not typically created or manipulated directly, but rather effected through the use of the [`checkout()`](../../plugins/development/staged-changes.md) context manager.
+
+### Branch
+
+The [branch](./branch.md) to which this change belongs.
+
+### Action
+
+The type of action this change represents: `create`, `update`, or `delete`.
+
+### Object
+
+A generic foreign key referencing the existing object to which this change applies.
+
+### Data
+
+JSON representation of the changes being made to the object (not applicable for deletions).
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/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md
index 5bb3dbd65..0f50fa75f 100644
--- a/docs/models/wireless/wirelesslan.md
+++ b/docs/models/wireless/wirelesslan.md
@@ -12,6 +12,13 @@ The service set identifier (SSID) for the wireless network.
The [wireless LAN group](./wirelesslangroup.md) to which this wireless LAN is assigned (if any).
+### Status
+
+The operational status of the wireless network.
+
+!!! tip
+ Additional statuses may be defined by setting `WirelessLAN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
+
### VLAN
Each wireless LAN can optionally be mapped to a [VLAN](../ipam/vlan.md), to model a bridge between wired and wireless segments.
diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md
index e1eefa7a5..d819b76cb 100644
--- a/docs/plugins/development/forms.md
+++ b/docs/plugins/development/forms.md
@@ -4,11 +4,11 @@
NetBox provides several base form classes for use by plugins.
-| Form Class | Purpose |
-|---------------------------|--------------------------------------|
-| `NetBoxModelForm` | Create/edit individual objects |
-| `NetBoxModelCSVForm` | Bulk import objects from CSV data |
-| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously |
+| Form Class | Purpose |
+|----------------------------|--------------------------------------|
+| `NetBoxModelForm` | Create/edit individual objects |
+| `NetBoxModelImportForm` | Bulk import objects from CSV data |
+| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously |
| `NetBoxModelFilterSetForm` | Filter objects within a list view |
### `NetBoxModelForm`
@@ -45,19 +45,20 @@ class MyModelForm(NetBoxModelForm):
!!! tip "Comment fields"
If your form has a `comments` field, there's no need to list it; this will always appear last on the page.
-### `NetBoxModelCSVForm`
+### `NetBoxModelImportForm`
-This form facilitates the bulk import of new objects from CSV data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below.
+This form facilitates the bulk import of new objects from CSV, JSON, or YAML data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below.
**Example**
```python
from dcim.models import Site
-from netbox.forms import NetBoxModelCSVForm
+from netbox.forms import NetBoxModelImportForm
from utilities.forms import CSVModelChoiceField
from .models import MyModel
-class MyModelCSVForm(NetBoxModelCSVForm):
+
+class MyModelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -69,6 +70,9 @@ class MyModelCSVForm(NetBoxModelCSVForm):
fields = ('name', 'status', 'site', 'comments')
```
+!!! note "Previously NetBoxModelCSVForm"
+ This form class was previously named `NetBoxModelCSVForm`. It was renamed in NetBox v3.4 to convey support for JSON and YAML formats in addition to CSV. The `NetBoxModelCSVForm` class has been retained for backward compatibility and functions exactly the same as `NetBoxModelImportForm`. However, plugin authors should be aware that this backward compatability will be removed in NetBox v3.5.
+
### `NetBoxModelBulkEditForm`
This form facilitates editing multiple objects in bulk. Unlike a model form, this form does not have a child `Meta` class, and must explicitly define each field. All fields in a bulk edit form are generally declared with `required=False`.
@@ -84,11 +88,12 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
```python
from django import forms
from dcim.models import Site
-from netbox.forms import NetBoxModelCSVForm
+from netbox.forms import NetBoxModelImportForm
from utilities.forms import CommentField, DynamicModelChoiceField
from .models import MyModel, MyModelStatusChoices
-class MyModelEditForm(NetBoxModelCSVForm):
+
+class MyModelEditForm(NetBoxModelImportForm):
name = forms.CharField(
required=False
)
@@ -144,73 +149,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
::: utilities.forms.ColorField
- selection:
+ options:
members: false
::: utilities.forms.CommentField
- selection:
+ options:
members: false
::: utilities.forms.JSONField
- selection:
+ options:
members: false
::: utilities.forms.MACAddressField
- selection:
+ options:
members: false
::: utilities.forms.SlugField
- selection:
+ options:
members: false
## Choice Fields
::: utilities.forms.ChoiceField
- selection:
+ options:
members: false
::: utilities.forms.MultipleChoiceField
- selection:
+ options:
members: false
## Dynamic Object Fields
::: utilities.forms.DynamicModelChoiceField
- selection:
+ options:
members: false
::: utilities.forms.DynamicModelMultipleChoiceField
- selection:
+ options:
members: false
## Content Type Fields
::: utilities.forms.ContentTypeChoiceField
- selection:
+ options:
members: false
::: utilities.forms.ContentTypeMultipleChoiceField
- selection:
+ options:
members: false
## CSV Import Fields
::: utilities.forms.CSVChoiceField
- selection:
+ options:
members: false
::: utilities.forms.CSVMultipleChoiceField
- selection:
+ options:
members: false
::: utilities.forms.CSVModelChoiceField
- selection:
+ options:
members: false
::: utilities.forms.CSVContentTypeField
- selection:
+ options:
members: false
::: utilities.forms.CSVMultipleContentTypeField
- selection:
+ options:
members: false
diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md
index 0dadf021f..f802e8025 100644
--- a/docs/plugins/development/graphql-api.md
+++ b/docs/plugins/development/graphql-api.md
@@ -32,11 +32,11 @@ schema = MyQuery
NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.BaseObjectType
- selection:
+ options:
members: false
::: netbox.graphql.types.NetBoxObjectType
- selection:
+ options:
members: false
## GraphQL Fields
@@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
NetBox provides two field classes for use by plugins.
::: netbox.graphql.fields.ObjectField
- selection:
+ options:
members: false
::: netbox.graphql.fields.ObjectListField
- selection:
+ options:
members: false
diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md
index 98db9e0bb..dcbad9d8d 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,10 +103,12 @@ 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 |
| `queues` | A list of custom background task queues to create |
+| `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
@@ -112,6 +116,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 using the `get_plugin_config()` function. For example:
+
+ ```python
+ from extras.plugins import get_plugin_config
+ get_plugin_config('my_plugin', '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..b3bcb292a 100644
--- a/docs/plugins/development/models.md
+++ b/docs/plugins/development/models.md
@@ -49,23 +49,11 @@ class MyModel(NetBoxModel):
...
```
-### The `clone()` Method
+### NetBoxModel Properties
-!!! info
- This method was introduced in NetBox v3.3.
+#### `docs_url`
-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
-```
+This attribute specifies the URL at which the documentation for this model can be reached. By default, it will return `/static/docs/models///`. Plugin models can override this to return a custom URL. For example, you might direct the user to your plugin's documentation hosted on [ReadTheDocs](https://readthedocs.org/).
### Enabling Features Individually
@@ -116,6 +104,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..63402c747 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_class='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/search.md b/docs/plugins/development/search.md
new file mode 100644
index 000000000..b6f24f58d
--- /dev/null
+++ b/docs/plugins/development/search.md
@@ -0,0 +1,31 @@
+# Search
+
+!!! note
+ This feature was introduced in NetBox v3.4.
+
+Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
+
+```python
+# search.py
+from netbox.search import SearchIndex
+from .models import MyModel
+
+class MyModelIndex(SearchIndex):
+ model = MyModel
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+```
+
+To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
+
+```python
+indexes = [MyModelIndex]
+```
+
+!!! tip
+ The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
+
+::: netbox.search.SearchIndex
diff --git a/docs/plugins/development/staged-changes.md b/docs/plugins/development/staged-changes.md
new file mode 100644
index 000000000..7a4446eea
--- /dev/null
+++ b/docs/plugins/development/staged-changes.md
@@ -0,0 +1,42 @@
+# Staged Changes
+
+!!! danger "Experimental Feature"
+ This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time.
+
+!!! note
+ This feature was introduced in NetBox v3.4.
+
+NetBox provides a programmatic API to stage the creation, modification, and deletion of objects without actually committing those changes to the active database. This can be useful for performing a "dry run" of bulk operations, or preparing a set of changes for administrative approval, for example.
+
+To begin staging changes, first create a [branch](../../models/extras/branch.md):
+
+```python
+from extras.models import Branch
+
+branch1 = Branch.objects.create(name='branch1')
+```
+
+Then, activate the branch using the `checkout()` context manager and begin making your changes. This initiates a new database transaction.
+
+```python
+from extras.models import Branch
+from netbox.staging import checkout
+
+branch1 = Branch.objects.get(name='branch1')
+with checkout(branch1):
+ Site.objects.create(name='New Site', slug='new-site')
+ # ...
+```
+
+Upon exiting the context, the database transaction is automatically rolled back and your changes recorded as [staged changes](../../models/extras/stagedchange.md). Re-entering a branch will trigger a new database transaction and automatically apply any staged changes associated with the branch.
+
+To apply the changes within a branch, call the branch's `commit()` method:
+
+```python
+from extras.models import Branch
+
+branch1 = Branch.objects.get(name='branch1')
+branch1.commit()
+```
+
+Committing a branch is an all-or-none operation: Any exceptions will revert the entire set of changes. After successfully committing a branch, all its associated StagedChange objects are automatically deleted (however the branch itself will remain and can be reused).
diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md
index 6dccb4ee2..f846139f0 100644
--- a/docs/plugins/development/tables.md
+++ b/docs/plugins/development/tables.md
@@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
::: netbox.tables.BooleanColumn
- selection:
+ options:
members: false
::: netbox.tables.ChoiceFieldColumn
- selection:
+ options:
members: false
::: netbox.tables.ColorColumn
- selection:
+ options:
members: false
::: netbox.tables.ColoredLabelColumn
- selection:
+ options:
members: false
::: netbox.tables.ContentTypeColumn
- selection:
+ options:
members: false
::: netbox.tables.ContentTypesColumn
- selection:
+ options:
members: false
::: netbox.tables.MarkdownColumn
- selection:
+ options:
members: false
::: netbox.tables.TagColumn
- selection:
+ options:
members: false
::: netbox.tables.TemplateColumn
- selection:
+ options:
members:
- __init__
diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md
index cabcd7045..7f8a64744 100644
--- a/docs/plugins/development/views.md
+++ b/docs/plugins/development/views.md
@@ -82,26 +82,28 @@ class ThingEditView(ObjectEditView):
Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseObjectView
+ options:
+ members:
+ - get_queryset
+ - get_object
+ - get_extra_context
::: netbox.views.generic.ObjectView
- selection:
+ options:
members:
- - get_object
- get_template_name
::: netbox.views.generic.ObjectEditView
- selection:
+ options:
members:
- - get_object
- alter_object
::: netbox.views.generic.ObjectDeleteView
- selection:
- members:
- - get_object
+ options:
+ members: false
::: netbox.views.generic.ObjectChildrenView
- selection:
+ options:
members:
- get_children
- prep_table_data
@@ -111,24 +113,29 @@ Below are the class definitions for NetBox's object views. These views handle CR
Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly.
::: netbox.views.generic.base.BaseMultiObjectView
+ options:
+ members:
+ - get_queryset
+ - get_extra_context
::: netbox.views.generic.ObjectListView
- selection:
+ options:
members:
- get_table
- export_table
- export_template
::: netbox.views.generic.BulkImportView
- selection:
- members: false
+ options:
+ members:
+ - save_object
::: netbox.views.generic.BulkEditView
- selection:
+ options:
members: false
::: netbox.views.generic.BulkDeleteView
- selection:
+ options:
members:
- get_form
@@ -137,29 +144,62 @@ Below are the class definitions for NetBox's multi-object views. These views han
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
::: netbox.views.generic.ObjectChangeLogView
- selection:
+ options:
members:
- get_form
::: netbox.views.generic.ObjectJournalView
- selection:
+ options:
members:
- get_form
## Extending Core Views
-Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available:
+### Additional Tabs
-* `left_page()` - Inject content on the left side of the page
-* `right_page()` - Inject content on the right side of the page
-* `full_width_page()` - Inject content across the entire bottom of the page
-* `buttons()` - Add buttons to the top of the page
+!!! note
+ This feature was introduced in NetBox v3.4.
+
+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, name='myview', 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 core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired method(s) to render custom content. Five methods are available:
+
+| Method | View | Description |
+|---------------------|-------------|-----------------------------------------------------|
+| `left_page()` | Object view | Inject content on the left side of the page |
+| `right_page()` | Object view | Inject content on the right side of the page |
+| `full_width_page()` | Object view | Inject content across the entire bottom of the page |
+| `buttons()` | Object view | Add buttons to the top of the page |
+| `list_buttons()` | List view | Add buttons to the top of the page |
Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
-* `object` - The object being viewed
+* `object` - The object being viewed (object views only)
+* `model` - The model of the list view (list views only)
* `request` - The current request
* `settings` - Global NetBox settings
* `config` - Plugin-specific configuration parameters
diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md
index 480595d56..94dd261a2 100644
--- a/docs/release-notes/version-3.3.md
+++ b/docs/release-notes/version-3.3.md
@@ -1,21 +1,189 @@
# NetBox v3.3
-## v3.3.3 (FUTURE)
+## v3.3.10 (2022-12-13)
### Enhancements
+* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
+* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
+* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
+* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
+* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
+* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
+* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
+
+### Bug Fixes
+
+* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
+* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
+* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
+* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
+* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
+* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
+* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
+* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
+* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
+
+---
+
+## v3.3.9 (2022-11-30)
+
+### Enhancements
+
+* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
+
+### Bug Fixes
+
+* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
+* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
+* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
+* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
+* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
+* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
+* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
+* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
+* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
+* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
+* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
+* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
+* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
+* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
+* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
+* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
+
+---
+
+## v3.3.8 (2022-11-16)
+
+### Enhancements
+
+* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
+* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
+* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
+* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
+* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
+* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
+* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
+
+### Bug Fixes
+
+* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
+* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
+* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
+* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
+* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
+* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
+* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
+* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
+* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
+
+---
+
+## v3.3.7 (2022-11-01)
+
+### Bug Fixes
+
+* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions
+* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users
+* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API
+* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments
+* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API
+
+---
+
+## v3.3.6 (2022-10-26)
+
+### Enhancements
+
+* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
+* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
+* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
+* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
+* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
+
+### Bug Fixes
+
+* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
+* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
+* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
+* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
+* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
+* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
+* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
+* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests
+* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
+* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
+* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
+* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
+* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
+* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
+* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
+
+---
+
+## 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
+
+---
+
+## v3.3.4 (2022-09-16)
+
+### Bug Fixes
+
+* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI
+* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface
+
+---
+
+## v3.3.3 (2022-09-15)
+
+### Enhancements
+
+* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected`
+* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types
* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI
+* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table
### Bug Fixes
* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters
+* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components
* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables
+* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules
* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed
* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services
* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments
* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field
* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links
+* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API
* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection
+* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import
+* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails
+* [#10353](https://github.com/netbox-community/netbox/issues/10353) - Table action buttons should reserve return URL parameters
+* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination
---
@@ -310,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
* Added the optional `device` field
* Added the `l2vpn_termination` read-only field
-wireless.WirelessLAN
+* wireless.WirelessLAN
* Added `tenant` field
-wireless.WirelessLink
+* wireless.WirelessLink
* Added `tenant` field
diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md
new file mode 100644
index 000000000..3abb894df
--- /dev/null
+++ b/docs/release-notes/version-3.4.md
@@ -0,0 +1,271 @@
+# NetBox v3.4
+
+## v3.4.3 (FUTURE)
+
+---
+
+## v3.4.2 (2023-01-03)
+
+### Enhancements
+
+* [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items
+* [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search
+* [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view
+* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI
+* [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name
+* [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list
+* [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled
+* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration
+
+### Bug Fixes
+
+* [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned
+* [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration
+* [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search
+* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components
+* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules
+
+---
+
+## v3.4.1 (2022-12-16)
+
+### Enhancements
+
+* [#9971](https://github.com/netbox-community/netbox/issues/9971) - Enable ordering of nested group models by name
+* [#11214](https://github.com/netbox-community/netbox/issues/11214) - Introduce the `DEFAULT_LANGUAGE` configuration parameter
+
+### Bug Fixes
+
+* [#11175](https://github.com/netbox-community/netbox/issues/11175) - Fix cloning of fields containing special characters
+* [#11178](https://github.com/netbox-community/netbox/issues/11178) - Pressing enter in quick search box should not trigger bulk operations
+* [#11184](https://github.com/netbox-community/netbox/issues/11184) - Correct visualization of cable path which splits across multiple circuit terminations
+* [#11185](https://github.com/netbox-community/netbox/issues/11185) - Fix TemplateSyntaxError when viewing custom script results
+* [#11189](https://github.com/netbox-community/netbox/issues/11189) - Fix localization of dates & numbers
+* [#11205](https://github.com/netbox-community/netbox/issues/11205) - Correct cloning behavior for recursively-nested models
+* [#11206](https://github.com/netbox-community/netbox/issues/11206) - Avoid clearing assigned groups if `REMOTE_AUTH_DEFAULT_GROUPS` is invalid
+
+---
+
+## v3.4.0 (2022-12-14)
+
+!!! 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" within the same site will raise a validation error.
+* The `asn`, `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 ASN and contact models introduced in NetBox v3.1 prior to upgrading.
+* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object.
+* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead.
+* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5.
+
+### New Features
+
+#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
+
+NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine.
+
+#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
+
+A new model representing virtual device contexts (VDCs) has been added. VDCs are logical partitions of resources within a device that can be managed independently. A VDC is created within a device and may have device interfaces assigned to it. An interface can be allocated to any number of VDCs on its device.
+
+#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
+
+Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string
+
+```
+?status=active®ion_id=12&tenant=acme
+```
+
+can be saved and applied to future queries as
+
+```
+?filter=my-custom-filter
+```
+
+Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches.
+
+#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
+
+NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user.
+
+#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
+
+NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects.
+
+#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
+
+Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours).
+
+#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
+
+This release introduces a new programmatic API that enables plugins and custom scripts to prepare changes in NetBox without actually committing them to the active database. To stage changes, create and activate a branch using the `checkout()` context manager. Any changes made within this context will be captured, recorded, and rolled back for future use. Once ready, a branch can be applied to the active database by calling `merge()`.
+
+!!! danger "Experimental Feature"
+ This feature is still under active development and considered experimental in nature. Its use in production is strongly discouraged at this time.
+
+### Enhancements
+
+* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
+* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search
+* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import
+* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
+* [#8274](https://github.com/netbox-community/netbox/issues/8274) - Enable associating a custom link with multiple object types
+* [#8485](https://github.com/netbox-community/netbox/issues/8485) - Enable journaling for all organizational models
+* [#8853](https://github.com/netbox-community/netbox/issues/8853) - Introduce the `ALLOW_TOKEN_RETRIEVAL` config parameter to restrict the display of API tokens
+* [#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
+* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
+* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
+* [#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
+* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules
+* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models
+* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
+* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
+* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
+* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns
+* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN
+* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
+* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts
+* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
+
+### Bug Fixes (from v3.4-beta1)
+
+* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned
+* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs
+* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs
+* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables
+* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table
+* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects
+* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL
+* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words
+* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data
+* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search
+* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search
+* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API
+
+### Plugins API
+
+* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method
+* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
+* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu
+* [#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) - Enable plugins to install and register other Django apps via `django_apps` attribute
+* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
+* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
+* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function
+* [#10739](https://github.com/netbox-community/netbox/issues/10739) - Introduce `get_queryset()` method on generic views
+
+### 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
+* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data
+* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
+* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk
+* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app
+* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function
+* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
+* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views
+* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo
+* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization
+
+### REST API Changes
+
+* Added the `/api/dcim/virtual-device-contexts/` endpoint
+* circuits.provider
+ * Removed the `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields
+ * Added a `description` field
+* dcim.Cable
+ * Added `description` and `comments` fields
+* dcim.Device
+ * Added a `description` field
+* dcim.DeviceType
+ * Added `description`, `weight`, and `weight_unit` fields
+* dcim.Module
+ * Added a `description` field
+* dcim.Interface
+ * Added the `vdcs` field
+* dcim.Module
+ * Added a required `status` field
+* dcim.ModuleType
+ * Added `description`, `weight`, and `weight_unit` fields
+* dcim.PowerFeed
+ * Added a `description` field
+* dcim.PowerPanel
+ * Added `description` and `comments` fields
+* dcim.Rack
+ * Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields
+* dcim.RackReservation
+ * Added a `comments` field
+* dcim.VirtualChassis
+ * Added `description` and `comments` fields
+* extras.CustomField
+ * Added a `search_weight` field
+* extras.CustomLink
+ * Renamed `content_type` field to `content_types`
+* extras.ExportTemplate
+ * Renamed `content_type` field to `content_types`
+* extras.JobResult
+ * Added `interval`, `scheduled`, and `started` fields
+* ipam.Aggregate
+ * Added a `comments` field
+* ipam.ASN
+ * Added a `comments` field
+* ipam.FHRPGroup
+ * Added `name` and `comments` fields
+* ipam.IPAddress
+ * Added a `comments` field
+* ipam.IPRange
+ * Added a `comments` field
+* ipam.L2VPN
+ * Added a `comments` field
+* ipam.Prefix
+ * Added a `comments` field
+* ipam.RouteTarget
+ * Added a `comments` field
+* ipam.Service
+ * Added a `comments` field
+* ipam.ServiceTemplate
+ * Added a `comments` field
+* ipam.VLAN
+ * Added a `comments` field
+* ipam.VRF
+ * Added a `comments` field
+* tenancy.Contact
+ * Added a `description` field
+* virtualization.Cluster
+ * Added a `description` field
+* virtualization.VirtualMachine
+ * Added a `description` field
+* wireless.WirelessLAN
+ * Added a required `status` choice field
+ * Added a `comments` field
+* wireless.WirelessLink
+ * Added a `comments` field
+
+### GraphQL API Changes
+
+* All object types now include a `display` field
+* All cabled object types now include a `link_peers` field
+* Add a `contacts` relationship for all relevant models
+* dcim.Cable
+ * Add A/B terminations fields
+* dcim.CableTermination
+ * Add `termination` field
+* dcim.InventoryItem
+ * Add `component` field
+* dcim.InventoryItemTemplate
+ * Add `component` field
+* dcim.Rack
+ * Add `mounting_depth` field
+* ipam.FHRPGroupAssignment
+ * Add `interface` field
+* ipam.IPAddress
+ * Add `assigned_object` field
+* ipam.L2VPNTermination
+ * Add `assigned_object` field
+* ipam.VLANGroupType
+ * Add `scope` field
diff --git a/mkdocs.yml b/mkdocs.yml
index 530c6d52e..2317dad6d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -30,7 +30,7 @@ plugins:
- os.chdir('netbox/')
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
- django.setup()
- rendering:
+ options:
heading_level: 3
members_order: source
show_root_heading: true
@@ -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
@@ -73,6 +72,7 @@ nav:
- Virtualization: 'features/virtualization.md'
- Tenancy: 'features/tenancy.md'
- Contacts: 'features/contacts.md'
+ - Search: 'features/search.md'
- Context Data: 'features/context-data.md'
- Change Logging: 'features/change-logging.md'
- Journaling: 'features/journaling.md'
@@ -129,9 +129,11 @@ nav:
- Tables: 'plugins/development/tables.md'
- Forms: 'plugins/development/forms.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md'
+ - Search: 'plugins/development/search.md'
- REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md'
- Background Tasks: 'plugins/development/background-tasks.md'
+ - Staged Changes: 'plugins/development/staged-changes.md'
- Exceptions: 'plugins/development/exceptions.md'
- Administration:
- Authentication:
@@ -190,13 +192,17 @@ nav:
- Site: 'models/dcim/site.md'
- SiteGroup: 'models/dcim/sitegroup.md'
- VirtualChassis: 'models/dcim/virtualchassis.md'
+ - VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
- Extras:
+ - Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md'
- CustomField: 'models/extras/customfield.md'
- CustomLink: 'models/extras/customlink.md'
- ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md'
- JournalEntry: 'models/extras/journalentry.md'
+ - SavedFilter: 'models/extras/savedfilter.md'
+ - StagedChange: 'models/extras/stagedchange.md'
- Tag: 'models/extras/tag.md'
- Webhook: 'models/extras/webhook.md'
- IPAM:
@@ -245,6 +251,7 @@ nav:
- Adding Models: 'development/adding-models.md'
- Extending Models: 'development/extending-models.md'
- Signals: 'development/signals.md'
+ - Search: 'development/search.md'
- Application Registry: 'development/application-registry.md'
- User Preferences: 'development/user-preferences.md'
- Web UI: 'development/web-ui.md'
@@ -252,6 +259,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..2bcb0895a 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -31,8 +31,8 @@ class ProviderSerializer(NetBoxModelSerializer):
class Meta:
model = Provider
fields = [
- 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
- 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
+ 'id', 'url', 'display', 'name', 'slug', 'account', 'description', 'comments', 'asns', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'circuit_count',
]
diff --git a/netbox/circuits/apps.py b/netbox/circuits/apps.py
index bc0b7d87d..3acf3b98c 100644
--- a/netbox/circuits/apps.py
+++ b/netbox/circuits/apps.py
@@ -6,4 +6,4 @@ class CircuitsConfig(AppConfig):
verbose_name = "Circuits"
def ready(self):
- import circuits.signals
+ from . import signals, search
diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py
index cee38fb18..8e4c9ab06 100644
--- a/netbox/circuits/filtersets.py
+++ b/netbox/circuits/filtersets.py
@@ -1,5 +1,6 @@
import django_filters
from django.db.models import Q
+from django.utils.translation import gettext as _
from dcim.filtersets import CabledObjectFilterSet
from dcim.models import Region, Site, SiteGroup
@@ -24,48 +25,48 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='circuits__terminations__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site',
queryset=Site.objects.all(),
- label='Site',
+ label=_('Site'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
asn_id = django_filters.ModelMultipleChoiceFilter(
field_name='asns',
queryset=ASN.objects.all(),
- label='ASN (ID)',
+ label=_('ASN (ID)'),
)
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 +74,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)
)
@@ -82,13 +81,13 @@ class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
class ProviderNetworkFilterSet(NetBoxModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
- label='Provider (ID)',
+ label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
- label='Provider (slug)',
+ label=_('Provider (slug)'),
)
class Meta:
@@ -116,28 +115,28 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
provider_id = django_filters.ModelMultipleChoiceFilter(
queryset=Provider.objects.all(),
- label='Provider (ID)',
+ label=_('Provider (ID)'),
)
provider = django_filters.ModelMultipleChoiceFilter(
field_name='provider__slug',
queryset=Provider.objects.all(),
to_field_name='slug',
- label='Provider (slug)',
+ label=_('Provider (slug)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__provider_network',
queryset=ProviderNetwork.objects.all(),
- label='ProviderNetwork (ID)',
+ label=_('ProviderNetwork (ID)'),
)
type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(),
- label='Circuit type (ID)',
+ label=_('Circuit type (ID)'),
)
type = django_filters.ModelMultipleChoiceFilter(
field_name='type__slug',
queryset=CircuitType.objects.all(),
to_field_name='slug',
- label='Circuit type (slug)',
+ label=_('Circuit type (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=CircuitStatusChoices,
@@ -147,38 +146,38 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
queryset=Region.objects.all(),
field_name='terminations__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='terminations__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site',
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
class Meta:
@@ -201,25 +200,25 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
q = django_filters.CharFilter(
method='search',
- label='Search',
+ label=_('Search'),
)
circuit_id = django_filters.ModelMultipleChoiceFilter(
queryset=Circuit.objects.all(),
- label='Circuit',
+ label=_('Circuit'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
provider_network_id = django_filters.ModelMultipleChoiceFilter(
queryset=ProviderNetwork.objects.all(),
- label='ProviderNetwork (ID)',
+ label=_('ProviderNetwork (ID)'),
)
class Meta:
diff --git a/netbox/circuits/forms/__init__.py b/netbox/circuits/forms/__init__.py
index 5c23f833a..1499f98b2 100644
--- a/netbox/circuits/forms/__init__.py
+++ b/netbox/circuits/forms/__init__.py
@@ -1,4 +1,4 @@
from .bulk_edit import *
from .bulk_import import *
from .filtersets import *
-from .models import *
+from .model_forms import *
diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py
index b6ba42afb..e1fe6338d 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'),
@@ -32,33 +28,23 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
account = forms.CharField(
max_length=30,
required=False,
- label='Account number'
+ label=_('Account number')
)
- portal_url = forms.URLField(
- required=False,
- label='Portal'
- )
- noc_contact = forms.CharField(
- required=False,
- widget=SmallTextarea,
- label='NOC contact'
- )
- admin_contact = forms.CharField(
- required=False,
- widget=SmallTextarea,
- label='Admin contact'
+ description = forms.CharField(
+ max_length=200,
+ required=False
)
comments = CommentField(
widget=SmallTextarea,
- label='Comments'
+ label=_('Comments')
)
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', 'description', 'comments',
)
@@ -70,7 +56,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
service_id = forms.CharField(
max_length=100,
required=False,
- label='Service ID'
+ label=_('Service ID')
)
description = forms.CharField(
max_length=200,
@@ -78,7 +64,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
)
comments = CommentField(
widget=SmallTextarea,
- label='Comments'
+ label=_('Comments')
)
model = ProviderNetwork
@@ -132,7 +118,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
)
commit_rate = forms.IntegerField(
required=False,
- label='Commit rate (Kbps)'
+ label=_('Commit rate (Kbps)')
)
description = forms.CharField(
max_length=100,
@@ -140,7 +126,7 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
)
comments = CommentField(
widget=SmallTextarea,
- label='Comments'
+ label=_('Comments')
)
model = Circuit
diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py
index cc2d0409a..b61fb1bc7 100644
--- a/netbox/circuits/forms/bulk_import.py
+++ b/netbox/circuits/forms/bulk_import.py
@@ -1,77 +1,102 @@
+from django import forms
+
from circuits.choices import CircuitStatusChoices
from circuits.models import *
-from netbox.forms import NetBoxModelCSVForm
+from dcim.models import Site
+from django.utils.translation import gettext as _
+from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
-from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
+from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
__all__ = (
- 'CircuitCSVForm',
- 'CircuitTypeCSVForm',
- 'ProviderCSVForm',
- 'ProviderNetworkCSVForm',
+ 'CircuitImportForm',
+ 'CircuitTerminationImportForm',
+ 'CircuitTypeImportForm',
+ 'ProviderImportForm',
+ 'ProviderNetworkImportForm',
)
-class ProviderCSVForm(NetBoxModelCSVForm):
+class ProviderImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = Provider
fields = (
- 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
+ 'name', 'slug', 'account', 'description', 'comments', 'tags',
)
-class ProviderNetworkCSVForm(NetBoxModelCSVForm):
+class ProviderNetworkImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
- help_text='Assigned provider'
+ help_text=_('Assigned provider')
)
class Meta:
model = ProviderNetwork
fields = [
- 'provider', 'name', 'service_id', 'description', 'comments',
+ 'provider', 'name', 'service_id', 'description', 'comments', 'tags'
]
-class CircuitTypeCSVForm(NetBoxModelCSVForm):
+class CircuitTypeImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = CircuitType
- fields = ('name', 'slug', 'description')
+ fields = ('name', 'slug', 'description', 'tags')
help_texts = {
- 'name': 'Name of circuit type',
+ 'name': _('Name of circuit type'),
}
-class CircuitCSVForm(NetBoxModelCSVForm):
+class CircuitImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
- help_text='Assigned provider'
+ help_text=_('Assigned provider')
)
type = CSVModelChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='name',
- help_text='Type of circuit'
+ help_text=_('Type of circuit')
)
status = CSVChoiceField(
choices=CircuitStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
class Meta:
model = Circuit
fields = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
- 'description', 'comments',
+ 'description', 'comments', 'tags'
+ ]
+
+
+class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
+ site = CSVModelChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='name',
+ required=False
+ )
+ provider_network = CSVModelChoiceField(
+ queryset=ProviderNetwork.objects.all(),
+ to_field_name='name',
+ required=False
+ )
+
+ class Meta:
+ model = CircuitTermination
+ fields = [
+ 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
+ 'pp_info', 'description',
]
diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py
index 29410ffdf..d7cfc494d 100644
--- a/netbox/circuits/forms/filtersets.py
+++ b/netbox/circuits/forms/filtersets.py
@@ -20,7 +20,7 @@ __all__ = (
class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)),
('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -59,7 +59,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
model = ProviderNetwork
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('provider_id', 'service_id')),
)
provider_id = DynamicModelMultipleChoiceField(
@@ -82,7 +82,7 @@ class CircuitTypeFilterForm(NetBoxModelFilterSetForm):
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Circuit
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Provider', ('provider_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/model_forms.py
similarity index 72%
rename from netbox/circuits/forms/models.py
rename to netbox/circuits/forms/model_forms.py
index 7bd7abbbf..cd73780fa 100644
--- a/netbox/circuits/forms/models.py
+++ b/netbox/circuits/forms/model_forms.py
@@ -1,4 +1,3 @@
-from django import forms
from django.utils.translation import gettext as _
from circuits.models import *
@@ -7,8 +6,8 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import (
- BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
- SelectSpeedWidget, SmallTextarea, SlugField, StaticSelect,
+ CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SlugField,
+ StaticSelect,
)
__all__ = (
@@ -30,29 +29,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', 'description', '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', 'description', '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",
+ 'name': _("Full name of the provider"),
}
@@ -76,6 +63,12 @@ class ProviderNetworkForm(NetBoxModelForm):
class CircuitTypeForm(NetBoxModelForm):
slug = SlugField()
+ fieldsets = (
+ ('Circuit Type', (
+ 'name', 'slug', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = CircuitType
fields = [
@@ -105,8 +98,8 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
'tenant_group', 'tenant', 'comments', 'tags',
]
help_texts = {
- 'cid': "Unique circuit ID",
- 'commit_rate': "Committed rate",
+ 'cid': _("Unique circuit ID"),
+ 'commit_rate': _("Committed rate"),
}
widgets = {
'status': StaticSelect(),
@@ -152,21 +145,33 @@ class CircuitTerminationForm(NetBoxModelForm):
},
required=False
)
+ provider_network_provider = DynamicModelChoiceField(
+ queryset=Provider.objects.all(),
+ required=False,
+ label='Provider',
+ initial_params={
+ 'networks': 'provider_network'
+ }
+ )
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
+ query_params={
+ 'provider_id': '$provider_network_provider',
+ },
required=False
)
class Meta:
model = CircuitTermination
fields = [
- 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
- 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
+ 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
+ 'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
+ 'description', 'tags',
]
help_texts = {
- 'port_speed': "Physical circuit speed",
- 'xconnect_id': "ID of the local cross-connect",
- 'pp_info': "Patch panel ID and port number(s)"
+ 'port_speed': _("Physical circuit speed"),
+ 'xconnect_id': _("ID of the local cross-connect"),
+ 'pp_info': _("Patch panel ID and port number(s)")
}
widgets = {
'term_side': StaticSelect(),
diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py
index e96fe98a5..5582de798 100644
--- a/netbox/circuits/graphql/types.py
+++ b/netbox/circuits/graphql/types.py
@@ -1,6 +1,8 @@
+import graphene
+
from circuits import filtersets, models
from dcim.graphql.mixins import CabledObjectMixin
-from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
+from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin
from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType
__all__ = (
@@ -20,8 +22,7 @@ class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, Ob
filterset_class = filtersets.CircuitTerminationFilterSet
-class CircuitType(NetBoxObjectType):
-
+class CircuitType(NetBoxObjectType, ContactsMixin):
class Meta:
model = models.Circuit
fields = '__all__'
@@ -36,7 +37,7 @@ class CircuitTypeType(OrganizationalObjectType):
filterset_class = filtersets.CircuitTypeFilterSet
-class ProviderType(NetBoxObjectType):
+class ProviderType(NetBoxObjectType, ContactsMixin):
class Meta:
model = models.Provider
diff --git a/netbox/circuits/migrations/0001_squashed.py b/netbox/circuits/migrations/0001_squashed.py
index 851f40a22..656eb35a1 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,14 +58,14 @@ 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)),
('description', models.CharField(blank=True, max_length=200)),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.CreateModel(
@@ -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/migrations/0041_standardize_description_comments.py b/netbox/circuits/migrations/0041_standardize_description_comments.py
new file mode 100644
index 000000000..49cdefcba
--- /dev/null
+++ b/netbox/circuits/migrations/0041_standardize_description_comments.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-11-03 18:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0040_provider_remove_deprecated_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='provider',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py
index c08b5473a..8ef5761fd 100644
--- a/netbox/circuits/models/circuits.py
+++ b/netbox/circuits/models/circuits.py
@@ -3,11 +3,12 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
+from django.utils.translation import gettext as _
from circuits.choices import *
from dcim.models import CabledObjectModel
from netbox.models import (
- ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin,
+ ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, PrimaryModel, TagsMixin,
)
from netbox.models.features import WebhooksMixin
@@ -23,30 +24,11 @@ class CircuitType(OrganizationalModel):
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
"Long Haul," "Metro," or "Out-of-Band".
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
def get_absolute_url(self):
return reverse('circuits:circuittype', args=[self.pk])
-class Circuit(NetBoxModel):
+class Circuit(PrimaryModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured
@@ -92,13 +74,6 @@ class Circuit(NetBoxModel):
blank=True,
null=True,
verbose_name='Commit rate (Kbps)')
- description = models.CharField(
- max_length=200,
- blank=True
- )
- comments = models.TextField(
- blank=True
- )
# Generic relations
contacts = GenericRelation(
@@ -129,18 +104,23 @@ class Circuit(NetBoxModel):
clone_fields = (
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
)
+ prerequisite_models = (
+ 'circuits.CircuitType',
+ 'circuits.Provider',
+ )
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
- @classmethod
- def get_prerequisite_models(cls):
- return [apps.get_model('circuits.Provider'), CircuitType]
-
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
@@ -189,7 +169,7 @@ class CircuitTermination(
blank=True,
null=True,
verbose_name='Upstream speed (Kbps)',
- help_text='Upstream speed, if different from port speed'
+ help_text=_('Upstream speed, if different from port speed')
)
xconnect_id = models.CharField(
max_length=50,
@@ -208,7 +188,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..18a81dcef 100644
--- a/netbox/circuits/models/providers.py
+++ b/netbox/circuits/models/providers.py
@@ -2,8 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
-from dcim.fields import ASNField
-from netbox.models import NetBoxModel
+from netbox.models import PrimaryModel
__all__ = (
'ProviderNetwork',
@@ -11,7 +10,7 @@ __all__ = (
)
-class Provider(NetBoxModel):
+class Provider(PrimaryModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
stores information pertinent to the user's relationship with the Provider.
@@ -24,12 +23,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,21 +33,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
- )
# Generic relations
contacts = GenericRelation(
@@ -62,7 +40,7 @@ class Provider(NetBoxModel):
)
clone_fields = (
- 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
+ 'account',
)
class Meta:
@@ -75,7 +53,7 @@ class Provider(NetBoxModel):
return reverse('circuits:provider', args=[self.pk])
-class ProviderNetwork(NetBoxModel):
+class ProviderNetwork(PrimaryModel):
"""
This represents a provider network which exists outside of NetBox, the details of which are unknown or
unimportant to the user.
@@ -93,23 +71,15 @@ class ProviderNetwork(NetBoxModel):
blank=True,
verbose_name='Service ID'
)
- description = models.CharField(
- max_length=200,
- blank=True
- )
- comments = models.TextField(
- blank=True
- )
class Meta:
ordering = ('provider', 'name')
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/search.py b/netbox/circuits/search.py
new file mode 100644
index 000000000..2859295d5
--- /dev/null
+++ b/netbox/circuits/search.py
@@ -0,0 +1,56 @@
+from netbox.search import SearchIndex, register_search
+from . import models
+
+
+@register_search
+class CircuitIndex(SearchIndex):
+ model = models.Circuit
+ fields = (
+ ('cid', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class CircuitTerminationIndex(SearchIndex):
+ model = models.CircuitTermination
+ fields = (
+ ('xconnect_id', 300),
+ ('pp_info', 300),
+ ('description', 500),
+ ('port_speed', 2000),
+ ('upstream_speed', 2000),
+ )
+
+
+@register_search
+class CircuitTypeIndex(SearchIndex):
+ model = models.CircuitType
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class ProviderIndex(SearchIndex):
+ model = models.Provider
+ fields = (
+ ('name', 100),
+ ('account', 200),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class ProviderNetworkIndex(SearchIndex):
+ model = models.ProviderNetwork
+ fields = (
+ ('name', 100),
+ ('service_id', 200),
+ ('description', 500),
+ ('comments', 5000),
+ )
diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py
index f9ab7e190..477f9c1ab 100644
--- a/netbox/circuits/tables/circuits.py
+++ b/netbox/circuits/tables/circuits.py
@@ -1,8 +1,9 @@
import django_tables2 as tables
-
from circuits.models import *
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
from .columns import CommitRateColumn
__all__ = (
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
-class CircuitTable(TenancyColumnsMixin, NetBoxTable):
+class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name='Circuit ID'
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
)
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)
diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py
index 0ec6d439d..9de8d25b2 100644
--- a/netbox/circuits/tables/providers.py
+++ b/netbox/circuits/tables/providers.py
@@ -1,7 +1,8 @@
import django_tables2 as tables
-from django_tables2.utils import Accessor
-
from circuits.models import *
+from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin
+
from netbox.tables import NetBoxTable, columns
__all__ = (
@@ -10,7 +11,7 @@ __all__ = (
)
-class ProviderTable(NetBoxTable):
+class ProviderTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
@@ -41,10 +39,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',
- 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'asns', 'account', 'asn_count', 'circuit_count', 'description', '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 abcfa8a00..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]}
@@ -344,6 +340,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
+ Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
@@ -357,6 +354,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
+ CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -364,7 +362,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_term_side(self):
params = {'term_side': 'A'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
def test_port_speed(self):
params = {'port_speed': ['1000', '2000']}
@@ -397,11 +395,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_provider_network(self):
provider_networks = ProviderNetwork.objects.all()[:2]
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_cabled(self):
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py
index 61e8cbbd1..d67b11ef9 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],
}
@@ -54,12 +50,15 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Provider 6,provider-6",
)
+ cls.csv_update_data = (
+ "id,name,comments",
+ f"{providers[0].pk},Provider 7,New comment7",
+ f"{providers[1].pk},Provider 8,New comment8",
+ f"{providers[2].pk},Provider 9,New comment9",
+ )
+
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',
}
@@ -70,11 +69,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- CircuitType.objects.bulk_create([
+ circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
- ])
+ )
+
+ CircuitType.objects.bulk_create(circuit_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -92,6 +93,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Circuit Type 6,circuit-type-6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{circuit_types[0].pk},Circuit Type 7,New description7",
+ f"{circuit_types[1].pk},Circuit Type 8,New description8",
+ f"{circuit_types[2].pk},Circuit Type 9,New description9",
+ )
+
cls.bulk_edit_data = {
'description': 'Foo',
}
@@ -100,12 +108,19 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit
+ def setUp(self):
+ super().setUp()
+
+ self.add_permissions(
+ 'circuits.add_circuittermination',
+ )
+
@classmethod
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)
@@ -115,11 +130,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
CircuitType.objects.bulk_create(circuittypes)
- Circuit.objects.bulk_create([
+ circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
- ])
+ )
+
+ Circuit.objects.bulk_create(circuits)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -144,6 +161,13 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Circuit 6,Provider 1,Circuit Type 1,active",
)
+ cls.csv_update_data = (
+ f"id,cid,description,status",
+ f"{circuits[0].pk},Circuit 7,New description7,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+ f"{circuits[1].pk},Circuit 8,New description8,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+ f"{circuits[2].pk},Circuit 9,New description9,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+ )
+
cls.bulk_edit_data = {
'provider': providers[1].pk,
'type': circuittypes[1].pk,
@@ -167,11 +191,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Provider.objects.bulk_create(providers)
- ProviderNetwork.objects.bulk_create([
+ provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
ProviderNetwork(name='Provider Network 2', provider=providers[0]),
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
- ])
+ )
+
+ ProviderNetwork.objects.bulk_create(provider_networks)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -190,6 +216,13 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Provider Network 6,Provider 1,Baz",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{provider_networks[0].pk},Provider Network 7,New description7",
+ f"{provider_networks[1].pk},Provider Network 8,New description8",
+ f"{provider_networks[2].pk},Provider Network 9,New description9",
+ )
+
cls.bulk_edit_data = {
'provider': providers[1].pk,
'description': 'New description',
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..3168509ba 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,18 +44,20 @@ 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()
class ProviderBulkImportView(generic.BulkImportView):
queryset = Provider.objects.all()
- model_form = forms.ProviderCSVForm
+ model_form = forms.ProviderImportForm
table = tables.ProviderTable
@@ -84,6 +89,7 @@ class ProviderNetworkListView(generic.ObjectListView):
table = tables.ProviderNetworkTable
+@register_model_view(ProviderNetwork)
class ProviderNetworkView(generic.ObjectView):
queryset = ProviderNetwork.objects.all()
@@ -103,18 +109,20 @@ 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()
class ProviderNetworkBulkImportView(generic.BulkImportView):
queryset = ProviderNetwork.objects.all()
- model_form = forms.ProviderNetworkCSVForm
+ model_form = forms.ProviderNetworkImportForm
table = tables.ProviderNetworkTable
@@ -144,6 +152,7 @@ class CircuitTypeListView(generic.ObjectListView):
table = tables.CircuitTypeTable
+@register_model_view(CircuitType)
class CircuitTypeView(generic.ObjectView):
queryset = CircuitType.objects.all()
@@ -157,18 +166,20 @@ 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()
class CircuitTypeBulkImportView(generic.BulkImportView):
queryset = CircuitType.objects.all()
- model_form = forms.CircuitTypeCSVForm
+ model_form = forms.CircuitTypeImportForm
table = tables.CircuitTypeTable
@@ -202,23 +213,36 @@ 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()
class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
- model_form = forms.CircuitCSVForm
+ model_form = forms.CircuitImportForm
table = tables.CircuitTable
+ additional_permissions = [
+ 'circuits.add_circuittermination',
+ ]
+ related_object_forms = {
+ 'terminations': forms.CircuitTerminationImportForm,
+ }
+
+ def prep_related_object_data(self, parent, data):
+ data.update({'circuit': parent})
+ return data
class CircuitBulkEditView(generic.BulkEditView):
@@ -318,11 +342,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/nested_serializers.py b/netbox/dcim/api/nested_serializers.py
index f5e06e155..29881a548 100644
--- a/netbox/dcim/api/nested_serializers.py
+++ b/netbox/dcim/api/nested_serializers.py
@@ -45,6 +45,7 @@ __all__ = [
'NestedSiteSerializer',
'NestedSiteGroupSerializer',
'NestedVirtualChassisSerializer',
+ 'NestedVirtualDeviceContextSerializer',
]
@@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
class Meta:
model = models.PowerFeed
fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
+
+
+class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
+ device = NestedDeviceSerializer()
+
+ class Meta:
+ model = models.VirtualDeviceContext
+ fields = ['id', 'url', 'display', 'name', 'identifier', 'device']
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 79f5339ad..f8a8d4e1e 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -1,6 +1,7 @@
import decimal
from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -130,7 +131,7 @@ class SiteSerializer(NetBoxModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
- time_zone = TimeZoneSerializerField(required=False)
+ time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField(
queryset=ASN.objects.all(),
serializer=NestedASNSerializer,
@@ -197,10 +198,11 @@ class RackSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
- facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label='Facility ID',
+ facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'),
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 +210,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', 'max_weight', 'weight_unit', 'desc_units',
+ 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
@@ -241,8 +244,8 @@ class RackReservationSerializer(NetBoxModelSerializer):
class Meta:
model = RackReservation
fields = [
- 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
- 'custom_fields',
+ 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
+ 'comments', 'tags', 'custom_fields',
]
@@ -309,33 +312,34 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
u_height = serializers.DecimalField(
max_digits=4,
decimal_places=1,
- label='Position (U)',
+ label=_('Position (U)'),
min_value=0,
default=1.0
)
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', 'description',
+ 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
]
class ModuleTypeSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
manufacturer = NestedManufacturerSerializer()
- # module_count = serializers.IntegerField(read_only=True)
+ weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False)
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', 'description',
+ 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
@@ -633,7 +637,7 @@ class DeviceSerializer(NetBoxModelSerializer):
max_digits=4,
decimal_places=1,
allow_null=True,
- label='Position (U)',
+ label=_('Position (U)'),
min_value=decimal.Decimal(0.5),
default=None
)
@@ -652,8 +656,8 @@ class DeviceSerializer(NetBoxModelSerializer):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
- 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
- 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
+ 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
@@ -668,17 +672,37 @@ class DeviceSerializer(NetBoxModelSerializer):
return data
+class VirtualDeviceContextSerializer(NetBoxModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
+ device = NestedDeviceSerializer()
+ tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
+ primary_ip = NestedIPAddressSerializer(read_only=True)
+ primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
+ primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
+
+ # Related object counts
+ interface_count = serializers.IntegerField(read_only=True)
+
+ class Meta:
+ model = VirtualDeviceContext
+ fields = [
+ 'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
+ 'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'interface_count',
+ ]
+
+
class ModuleSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
device = NestedDeviceSerializer()
module_bay = NestedModuleBaySerializer()
module_type = NestedModuleTypeSerializer()
+ status = ChoiceField(choices=ModuleStatusChoices, required=False)
class Meta:
model = Module
fields = [
- 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
- 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
+ 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
@@ -820,6 +844,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
+ vdcs = SerializedPKRelatedField(
+ queryset=VirtualDeviceContext.objects.all(),
+ serializer=NestedVirtualDeviceContextSerializer,
+ required=False,
+ many=True
+ )
module = ComponentNestedModuleSerializer(
required=False,
allow_null=True
@@ -856,13 +886,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
class Meta:
model = Interface
fields = [
- 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
- 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
- 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
- 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
- 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
- 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
- 'count_fhrp_groups', '_occupied',
+ 'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
+ 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
+ 'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+ 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
+ 'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
+ 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
+ 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
]
def validate(self, data):
@@ -1016,7 +1046,7 @@ class CableSerializer(NetBoxModelSerializer):
model = Cable
fields = [
'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
- 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
@@ -1076,14 +1106,14 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
- master = NestedDeviceSerializer(required=False)
+ master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = [
- 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
- 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
+ 'member_count', 'created', 'last_updated',
]
@@ -1104,8 +1134,8 @@ class PowerPanelSerializer(NetBoxModelSerializer):
class Meta:
model = PowerPanel
fields = [
- 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
- 'created', 'last_updated',
+ 'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
+ 'powerfeed_count', 'created', 'last_updated',
]
@@ -1138,7 +1168,7 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
- 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers',
- 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable',
- 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
+ 'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
+ 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
+ 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py
index 47bbfd525..36a0c99a5 100644
--- a/netbox/dcim/api/urls.py
+++ b/netbox/dcim/api/urls.py
@@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet)
router.register('device-roles', views.DeviceRoleViewSet)
router.register('platforms', views.PlatformViewSet)
router.register('devices', views.DeviceViewSet)
+router.register('virtual-device-contexts', views.VirtualDeviceContextViewSet)
router.register('modules', views.ModuleViewSet)
# Device components
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index c18eab01f..954c6a2a5 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -538,6 +538,16 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
return Response(response)
+class VirtualDeviceContextViewSet(NetBoxModelViewSet):
+ queryset = VirtualDeviceContext.objects.prefetch_related(
+ 'device__device_type', 'device', 'tenant', 'tags',
+ ).annotate(
+ interface_count=count_related(Interface, 'vdcs'),
+ )
+ serializer_class = serializers.VirtualDeviceContextSerializer
+ filterset_class = filtersets.VirtualDeviceContextFilterSet
+
+
class ModuleViewSet(NetBoxModelViewSet):
queryset = Module.objects.prefetch_related(
'device', 'module_bay', 'module_type__manufacturer', 'tags',
diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py
index 4be2df659..bfb09e601 100644
--- a/netbox/dcim/apps.py
+++ b/netbox/dcim/apps.py
@@ -8,7 +8,7 @@ class DCIMConfig(AppConfig):
verbose_name = "DCIM"
def ready(self):
- import dcim.signals
+ from . import signals, search
from .models import CableTermination
# Register denormalized fields
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 7d35a40f9..32dbbb62a 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
TYPE_4POST = '4-post-frame'
TYPE_CABINET = '4-post-cabinet'
TYPE_WALLFRAME = 'wall-frame'
+ TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
TYPE_WALLCABINET = 'wall-cabinet'
+ TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
CHOICES = (
(TYPE_2POST, '2-post frame'),
(TYPE_4POST, '4-post frame'),
(TYPE_CABINET, '4-post cabinet'),
(TYPE_WALLFRAME, 'Wall-mounted frame'),
+ (TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
+ (TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
)
@@ -194,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet):
)
+#
+# Modules
+#
+
+class ModuleStatusChoices(ChoiceSet):
+ key = 'Module.status'
+
+ STATUS_OFFLINE = 'offline'
+ STATUS_ACTIVE = 'active'
+ STATUS_PLANNED = 'planned'
+ STATUS_STAGED = 'staged'
+ STATUS_FAILED = 'failed'
+ STATUS_DECOMMISSIONING = 'decommissioning'
+
+ CHOICES = [
+ (STATUS_OFFLINE, 'Offline', 'gray'),
+ (STATUS_ACTIVE, 'Active', 'green'),
+ (STATUS_PLANNED, 'Planned', 'cyan'),
+ (STATUS_STAGED, 'Staged', 'blue'),
+ (STATUS_FAILED, 'Failed', 'red'),
+ (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
+ ]
+
+
#
# ConsolePorts
#
@@ -783,6 +811,17 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
+ # Ethernet Backplane
+ TYPE_1GE_KX = '1000base-kx'
+ TYPE_10GE_KR = '10gbase-kr'
+ TYPE_10GE_KX4 = '10gbase-kx4'
+ TYPE_25GE_KR = '25gbase-kr'
+ TYPE_40GE_KR4 = '40gbase-kr4'
+ TYPE_50GE_KR = '50gbase-kr'
+ TYPE_100GE_KP4 = '100gbase-kp4'
+ TYPE_100GE_KR2 = '100gbase-kr2'
+ TYPE_100GE_KR4 = '100gbase-kr4'
+
# Wireless
TYPE_80211A = 'ieee802.11a'
TYPE_80211G = 'ieee802.11g'
@@ -911,6 +950,20 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
)
),
+ (
+ 'Ethernet (backplane)',
+ (
+ (TYPE_1GE_KX, '1000BASE-KX (1GE)'),
+ (TYPE_10GE_KR, '10GBASE-KR (10GE)'),
+ (TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
+ (TYPE_25GE_KR, '25GBASE-KR (25GE)'),
+ (TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
+ (TYPE_50GE_KR, '50GBASE-KR (50GE)'),
+ (TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
+ (TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
+ (TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
+ )
+ ),
(
'Wireless',
(
@@ -1314,6 +1367,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
#
@@ -1381,3 +1452,20 @@ class PowerFeedPhaseChoices(ChoiceSet):
(PHASE_SINGLE, 'Single phase'),
(PHASE_3PHASE, 'Three-phase'),
)
+
+
+#
+# VDC
+#
+class VirtualDeviceContextStatusChoices(ChoiceSet):
+ key = 'VirtualDeviceContext.status'
+
+ STATUS_ACTIVE = 'active'
+ STATUS_PLANNED = 'planned'
+ STATUS_OFFLINE = 'offline'
+
+ CHOICES = [
+ (STATUS_ACTIVE, 'Active', 'green'),
+ (STATUS_PLANNED, 'Planned', 'cyan'),
+ (STATUS_OFFLINE, 'Offline', 'red'),
+ ]
diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py
index d3afe5c08..4a2755be9 100644
--- a/netbox/dcim/fields.py
+++ b/netbox/dcim/fields.py
@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
def to_python(self, value):
if value is None:
return value
+ if type(value) is str:
+ value = value.replace(' ', '')
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError:
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 5d92af878..534d8e411 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1,8 +1,9 @@
import django_filters
from django.contrib.auth.models import User
+from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet
-from ipam.models import ASN, VRF
+from ipam.models import ASN, IPAddress, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
@@ -65,19 +66,20 @@ __all__ = (
'SiteFilterSet',
'SiteGroupFilterSet',
'VirtualChassisFilterSet',
+ 'VirtualDeviceContextFilterSet',
)
class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
- label='Parent region (ID)',
+ label=_('Parent region (ID)'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=Region.objects.all(),
to_field_name='slug',
- label='Parent region (slug)',
+ label=_('Parent region (slug)'),
)
class Meta:
@@ -88,13 +90,13 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- label='Parent site group (ID)',
+ label=_('Parent site group (ID)'),
)
parent = django_filters.ModelMultipleChoiceFilter(
field_name='parent__slug',
queryset=SiteGroup.objects.all(),
to_field_name='slug',
- label='Parent site group (slug)',
+ label=_('Parent site group (slug)'),
)
class Meta:
@@ -111,36 +113,36 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
queryset=Region.objects.all(),
field_name='region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='group',
lookup_expr='in',
- label='Group (ID)',
+ label=_('Group (ID)'),
)
group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
lookup_expr='in',
to_field_name='slug',
- label='Group (slug)',
+ label=_('Group (slug)'),
)
asn = django_filters.ModelMultipleChoiceFilter(
field_name='asns__asn',
queryset=ASN.objects.all(),
to_field_name='asn',
- label='AS (ID)',
+ label=_('AS (ID)'),
)
asn_id = django_filters.ModelMultipleChoiceFilter(
field_name='asns',
queryset=ASN.objects.all(),
- label='AS (ID)',
+ label=_('AS (ID)'),
)
class Meta:
@@ -172,50 +174,50 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
parent_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='parent',
lookup_expr='in',
- label='Location (ID)',
+ label=_('Location (ID)'),
)
parent = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='parent',
lookup_expr='in',
to_field_name='slug',
- label='Location (slug)',
+ label=_('Location (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=LocationStatusChoices,
@@ -247,50 +249,50 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
- label='Location (ID)',
+ label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
to_field_name='slug',
- label='Location (slug)',
+ label=_('Location (slug)'),
)
status = django_filters.MultipleChoiceFilter(
choices=RackStatusChoices,
@@ -304,13 +306,13 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackRole.objects.all(),
- label='Role (ID)',
+ label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=RackRole.objects.all(),
to_field_name='slug',
- label='Role (slug)',
+ label=_('Role (slug)'),
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
@@ -320,7 +322,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', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
]
def search(self, queryset, name, value):
@@ -338,67 +340,67 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
rack_id = django_filters.ModelMultipleChoiceFilter(
queryset=Rack.objects.all(),
- label='Rack (ID)',
+ label=_('Rack (ID)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack__site',
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='rack__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site (slug)',
+ label=_('Site (slug)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='rack__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='rack__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',
lookup_expr='in',
- label='Location (ID)',
+ label=_('Location (ID)'),
)
location = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='rack__location',
lookup_expr='in',
to_field_name='slug',
- label='Location (slug)',
+ label=_('Location (slug)'),
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
- label='User (ID)',
+ label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
to_field_name='username',
- label='User (name)',
+ label=_('User (name)'),
)
class Meta:
@@ -426,55 +428,63 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet)
class DeviceTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
+ )
+ has_front_image = django_filters.BooleanFilter(
+ label=_('Has a front image'),
+ method='_has_front_image'
+ )
+ has_rear_image = django_filters.BooleanFilter(
+ label=_('Has a rear image'),
+ method='_has_rear_image'
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
- label='Has console ports',
+ label=_('Has console ports'),
)
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
- label='Has console server ports',
+ label=_('Has console server ports'),
)
power_ports = django_filters.BooleanFilter(
method='_power_ports',
- label='Has power ports',
+ label=_('Has power ports'),
)
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
- label='Has power outlets',
+ label=_('Has power outlets'),
)
interfaces = django_filters.BooleanFilter(
method='_interfaces',
- label='Has interfaces',
+ label=_('Has interfaces'),
)
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
- label='Has pass-through ports',
+ label=_('Has pass-through ports'),
)
module_bays = django_filters.BooleanFilter(
method='_module_bays',
- label='Has module bays',
+ label=_('Has module bays'),
)
device_bays = django_filters.BooleanFilter(
method='_device_bays',
- label='Has device bays',
+ label=_('Has device bays'),
)
inventory_items = django_filters.BooleanFilter(
method='_inventory_items',
- label='Has inventory items',
+ label=_('Has inventory items'),
)
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):
@@ -487,6 +497,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
Q(comments__icontains=value)
)
+ def _has_front_image(self, queryset, name, value):
+ if value:
+ return queryset.exclude(front_image='')
+ else:
+ return queryset.filter(front_image='')
+
+ def _has_rear_image(self, queryset, name, value):
+ if value:
+ return queryset.exclude(rear_image='')
+ else:
+ return queryset.filter(rear_image='')
+
def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleporttemplates__isnull=value)
@@ -521,42 +543,42 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
class ModuleTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
- label='Has console ports',
+ label=_('Has console ports'),
)
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
- label='Has console server ports',
+ label=_('Has console server ports'),
)
power_ports = django_filters.BooleanFilter(
method='_power_ports',
- label='Has power ports',
+ label=_('Has power ports'),
)
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
- label='Has power outlets',
+ label=_('Has power outlets'),
)
interfaces = django_filters.BooleanFilter(
method='_interfaces',
- label='Has interfaces',
+ label=_('Has interfaces'),
)
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
- label='Has pass-through ports',
+ label=_('Has pass-through ports'),
)
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():
@@ -593,12 +615,12 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class DeviceTypeComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
- label='Search',
+ label=_('Search'),
)
devicetype_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
field_name='device_type_id',
- label='Device type (ID)',
+ label=_('Device type (ID)'),
)
def search(self, queryset, name, value):
@@ -611,7 +633,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
moduletype_id = django_filters.ModelMultipleChoiceFilter(
queryset=ModuleType.objects.all(),
field_name='module_type_id',
- label='Module type (ID)',
+ label=_('Module type (ID)'),
)
@@ -703,27 +725,27 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItemTemplate.objects.all(),
- label='Parent inventory item (ID)',
+ label=_('Parent inventory item (ID)'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItemRole.objects.all(),
- label='Role (ID)',
+ label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=InventoryItemRole.objects.all(),
to_field_name='slug',
- label='Role (slug)',
+ label=_('Role (slug)'),
)
component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter()
@@ -754,13 +776,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer',
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
)
class Meta:
@@ -772,100 +794,109 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer',
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
+ )
+ device_type = django_filters.ModelMultipleChoiceFilter(
+ field_name='device_type__slug',
+ queryset=DeviceType.objects.all(),
+ to_field_name='slug',
+ label=_('Device type (slug)'),
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
- label='Device type (ID)',
+ label=_('Device type (ID)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_role_id',
queryset=DeviceRole.objects.all(),
- label='Role (ID)',
+ label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
- label='Role (slug)',
+ label=_('Role (slug)'),
)
parent_device_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent_bay__device',
queryset=Device.objects.all(),
- label='Parent Device (ID)',
+ label=_('Parent Device (ID)'),
)
platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
- label='Platform (ID)',
+ label=_('Platform (ID)'),
)
platform = django_filters.ModelMultipleChoiceFilter(
field_name='platform__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
- label='Platform (slug)',
+ label=_('Platform (slug)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site name (slug)',
+ label=_('Site name (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
- label='Location (ID)',
+ label=_('Location (ID)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
queryset=Rack.objects.all(),
- label='Rack (ID)',
+ label=_('Rack (ID)'),
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
- label='VM cluster (ID)',
+ label=_('VM cluster (ID)'),
)
model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug',
queryset=DeviceType.objects.all(),
to_field_name='slug',
- label='Device model (slug)',
+ label=_('Device model (slug)'),
+ )
+ name = MultiValueCharFilter(
+ lookup_expr='iexact'
)
status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices,
@@ -873,64 +904,74 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
)
is_full_depth = django_filters.BooleanFilter(
field_name='device_type__is_full_depth',
- label='Is full depth',
+ label=_('Is full depth'),
)
mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address',
- label='MAC address',
+ label=_('MAC address'),
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
- label='Has a primary IP',
+ label=_('Has a primary IP'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(),
- label='Virtual chassis (ID)',
+ label=_('Virtual chassis (ID)'),
)
virtual_chassis_member = django_filters.BooleanFilter(
method='_virtual_chassis_member',
- label='Is a virtual chassis member'
+ label=_('Is a virtual chassis member')
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
- label='Has console ports',
+ label=_('Has console ports'),
)
console_server_ports = django_filters.BooleanFilter(
method='_console_server_ports',
- label='Has console server ports',
+ label=_('Has console server ports'),
)
power_ports = django_filters.BooleanFilter(
method='_power_ports',
- label='Has power ports',
+ label=_('Has power ports'),
)
power_outlets = django_filters.BooleanFilter(
method='_power_outlets',
- label='Has power outlets',
+ label=_('Has power outlets'),
)
interfaces = django_filters.BooleanFilter(
method='_interfaces',
- label='Has interfaces',
+ label=_('Has interfaces'),
)
pass_through_ports = django_filters.BooleanFilter(
method='_pass_through_ports',
- label='Has pass-through ports',
+ label=_('Has pass-through ports'),
)
module_bays = django_filters.BooleanFilter(
method='_module_bays',
- label='Has module bays',
+ label=_('Has module bays'),
)
device_bays = django_filters.BooleanFilter(
method='_device_bays',
- label='Has device bays',
+ label=_('Has device bays'),
+ )
+ primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_ip4',
+ queryset=IPAddress.objects.all(),
+ label=_('Primary IPv4 (ID)'),
+ )
+ primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='primary_ip6',
+ queryset=IPAddress.objects.all(),
+ label=_('Primary IPv6 (ID)'),
)
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():
@@ -980,38 +1021,80 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.exclude(devicebays__isnull=value)
+class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='device',
+ queryset=Device.objects.all(),
+ label='VDC (ID)',
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='device',
+ queryset=Device.objects.all(),
+ label='Device model',
+ )
+ status = django_filters.MultipleChoiceFilter(
+ choices=VirtualDeviceContextStatusChoices
+ )
+ has_primary_ip = django_filters.BooleanFilter(
+ method='_has_primary_ip',
+ label='Has a primary IP',
+ )
+
+ class Meta:
+ model = VirtualDeviceContext
+ fields = ['id', 'device', 'name']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(identifier=value.strip())
+ ).distinct()
+
+ def _has_primary_ip(self, queryset, name, value):
+ params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
+ if value:
+ return queryset.filter(params)
+ return queryset.exclude(params)
+
+
class ModuleFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_type__manufacturer',
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='module_type__manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
)
module_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_type',
queryset=ModuleType.objects.all(),
- label='Module type (ID)',
+ label=_('Module type (ID)'),
)
module_type = django_filters.ModelMultipleChoiceFilter(
field_name='module_type__model',
queryset=ModuleType.objects.all(),
to_field_name='model',
- label='Module type (model)',
+ label=_('Module type (model)'),
)
module_bay_id = django_filters.ModelMultipleChoiceFilter(
field_name='module_bay',
queryset=ModuleBay.objects.all(),
to_field_name='id',
- label='Module Bay (ID)'
+ label=_('Module Bay (ID)')
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
- label='Device (ID)',
+ label=_('Device (ID)'),
+ )
+ status = django_filters.MultipleChoiceFilter(
+ choices=ModuleStatusChoices,
+ null_value=None
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
@@ -1019,12 +1102,13 @@ class ModuleFilterSet(NetBoxModelFilterSet):
class Meta:
model = Module
- fields = ['id', 'asset_tag']
+ fields = ['id', 'status', 'asset_tag']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
+ Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)
@@ -1034,87 +1118,87 @@ class ModuleFilterSet(NetBoxModelFilterSet):
class DeviceComponentFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
- label='Search',
+ label=_('Search'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='device__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site name (slug)',
+ label=_('Site name (slug)'),
)
location_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__location',
queryset=Location.objects.all(),
- label='Location (ID)',
+ label=_('Location (ID)'),
)
location = django_filters.ModelMultipleChoiceFilter(
field_name='device__location__slug',
queryset=Location.objects.all(),
to_field_name='slug',
- label='Location (slug)',
+ label=_('Location (slug)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
queryset=Rack.objects.all(),
- label='Rack (ID)',
+ label=_('Rack (ID)'),
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
- label='Rack (name)',
+ label=_('Rack (name)'),
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
- label='Device (ID)',
+ label=_('Device (ID)'),
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
- label='Device (name)',
+ label=_('Device (name)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
- label='Virtual Chassis (ID)'
+ label=_('Virtual Chassis (ID)')
)
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis__name',
queryset=VirtualChassis.objects.all(),
to_field_name='name',
- label='Virtual Chassis',
+ label=_('Virtual Chassis'),
)
def search(self, queryset, name, value):
@@ -1134,7 +1218,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet):
"""
module_id = django_filters.ModelMultipleChoiceFilter(
queryset=Module.objects.all(),
- label='Module (ID)',
+ label=_('Module (ID)'),
)
@@ -1144,6 +1228,15 @@ class CabledObjectFilterSet(django_filters.FilterSet):
lookup_expr='isnull',
exclude=True
)
+ occupied = django_filters.BooleanFilter(
+ method='filter_occupied'
+ )
+
+ def filter_occupied(self, queryset, name, value):
+ if value:
+ return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True))
+ else:
+ return queryset.filter(cable__isnull=True, mark_connected=False)
class PathEndpointFilterSet(django_filters.FilterSet):
@@ -1237,31 +1330,31 @@ class InterfaceFilterSet(
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
- label='Device',
+ label=_('Device'),
)
device_id = MultiValueNumberFilter(
method='filter_device_id',
field_name='pk',
- label='Device (ID)',
+ label=_('Device (ID)'),
)
kind = django_filters.CharFilter(
method='filter_kind',
- label='Kind of interface',
+ label=_('Kind of interface'),
)
parent_id = django_filters.ModelMultipleChoiceFilter(
field_name='parent',
queryset=Interface.objects.all(),
- label='Parent interface (ID)',
+ label=_('Parent interface (ID)'),
)
bridge_id = django_filters.ModelMultipleChoiceFilter(
field_name='bridge',
queryset=Interface.objects.all(),
- label='Bridged interface (ID)',
+ label=_('Bridged interface (ID)'),
)
lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag',
queryset=Interface.objects.all(),
- label='LAG interface (ID)',
+ label=_('LAG interface (ID)'),
)
speed = MultiValueNumberFilter()
duplex = django_filters.MultipleChoiceFilter(
@@ -1277,11 +1370,11 @@ class InterfaceFilterSet(
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
- label='Assigned VLAN'
+ label=_('Assigned VLAN')
)
vlan = django_filters.CharFilter(
method='filter_vlan',
- label='Assigned VID'
+ label=_('Assigned VID')
)
type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices,
@@ -1296,13 +1389,30 @@ class InterfaceFilterSet(
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
- label='VRF',
+ label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
- label='VRF (RD)',
+ label=_('VRF (RD)'),
+ )
+ vdc_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='vdcs',
+ queryset=VirtualDeviceContext.objects.all(),
+ label='Virtual Device Context',
+ )
+ vdc_identifier = django_filters.ModelMultipleChoiceFilter(
+ field_name='vdcs__identifier',
+ queryset=VirtualDeviceContext.objects.all(),
+ to_field_name='identifier',
+ label='Virtual Device Context (Identifier)',
+ )
+ vdc = django_filters.ModelMultipleChoiceFilter(
+ field_name='vdcs__name',
+ queryset=VirtualDeviceContext.objects.all(),
+ to_field_name='name',
+ label='Virtual Device Context',
)
class Meta:
@@ -1328,7 +1438,7 @@ class InterfaceFilterSet(
try:
devices = Device.objects.filter(pk__in=id_list)
for device in devices:
- vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
+ vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
@@ -1407,27 +1517,27 @@ class DeviceBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(),
- label='Parent inventory item (ID)',
+ label=_('Parent inventory item (ID)'),
)
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
- label='Manufacturer (ID)',
+ label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
- label='Manufacturer (slug)',
+ label=_('Manufacturer (slug)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItemRole.objects.all(),
- label='Role (ID)',
+ label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='role__slug',
queryset=InventoryItemRole.objects.all(),
to_field_name='slug',
- label='Role (slug)',
+ label=_('Role (slug)'),
)
component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter()
@@ -1462,61 +1572,61 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
class VirtualChassisFilterSet(NetBoxModelFilterSet):
master_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
- label='Master (ID)',
+ label=_('Master (ID)'),
)
master = django_filters.ModelMultipleChoiceFilter(
field_name='master__name',
queryset=Device.objects.all(),
to_field_name='name',
- label='Master (name)',
+ label=_('Master (name)'),
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='master__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='master__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site',
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='master__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site name (slug)',
+ label=_('Site name (slug)'),
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__tenant',
queryset=Tenant.objects.all(),
- label='Tenant (ID)',
+ label=_('Tenant (ID)'),
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='master__tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
- label='Tenant (slug)',
+ label=_('Tenant (slug)'),
)
class Meta:
@@ -1615,43 +1725,43 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site name (slug)',
+ label=_('Site name (slug)'),
)
location_id = TreeNodeMultipleChoiceFilter(
queryset=Location.objects.all(),
field_name='location',
lookup_expr='in',
- label='Location (ID)',
+ label=_('Location (ID)'),
)
class Meta:
@@ -1672,47 +1782,47 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
queryset=Region.objects.all(),
field_name='power_panel__site__region',
lookup_expr='in',
- label='Region (ID)',
+ label=_('Region (ID)'),
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',
lookup_expr='in',
to_field_name='slug',
- label='Region (slug)',
+ label=_('Region (slug)'),
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='power_panel__site__group',
lookup_expr='in',
- label='Site group (ID)',
+ label=_('Site group (ID)'),
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
field_name='power_panel__site__group',
lookup_expr='in',
to_field_name='slug',
- label='Site group (slug)',
+ label=_('Site group (slug)'),
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site',
queryset=Site.objects.all(),
- label='Site (ID)',
+ label=_('Site (ID)'),
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
- label='Site name (slug)',
+ label=_('Site name (slug)'),
)
power_panel_id = django_filters.ModelMultipleChoiceFilter(
queryset=PowerPanel.objects.all(),
- label='Power panel (ID)',
+ label=_('Power panel (ID)'),
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
queryset=Rack.objects.all(),
- label='Rack (ID)',
+ label=_('Rack (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=PowerFeedStatusChoices,
@@ -1742,7 +1852,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
class ConnectionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
- label='Search',
+ label=_('Search'),
)
site_id = MultiValueNumberFilter(
method='filter_connections',
diff --git a/netbox/dcim/forms/__init__.py b/netbox/dcim/forms/__init__.py
index 22f0b1204..7510a979f 100644
--- a/netbox/dcim/forms/__init__.py
+++ b/netbox/dcim/forms/__init__.py
@@ -1,4 +1,4 @@
-from .models import *
+from .model_forms import *
from .filtersets import *
from .object_create import *
from .object_import import *
diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py
index 43b852928..11fdfa6d2 100644
--- a/netbox/dcim/forms/bulk_create.py
+++ b/netbox/dcim/forms/bulk_create.py
@@ -1,9 +1,10 @@
from django import forms
from dcim.models import *
+from django.utils.translation import gettext as _
from extras.forms import CustomFieldsMixin
from extras.models import Tag
-from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
+from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
from .object_create import ComponentCreateForm
__all__ = (
@@ -24,7 +25,7 @@ __all__ = (
# Device components
#
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
+class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -37,6 +38,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
queryset=Tag.objects.all(),
required=False
)
+ replication_fields = ('name', 'label')
class ConsolePortBulkCreateForm(
@@ -44,7 +46,7 @@ class ConsolePortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = ConsolePort
- field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
+ field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags')
class ConsoleServerPortBulkCreateForm(
@@ -52,7 +54,7 @@ class ConsoleServerPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = ConsoleServerPort
- field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
+ field_order = ('name', 'label', 'type', 'speed', 'description', 'tags')
class PowerPortBulkCreateForm(
@@ -60,7 +62,7 @@ class PowerPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = PowerPort
- field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
+ field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
class PowerOutletBulkCreateForm(
@@ -68,7 +70,7 @@ class PowerOutletBulkCreateForm(
DeviceBulkAddComponentForm
):
model = PowerOutlet
- field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
+ field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
class InterfaceBulkCreateForm(
@@ -79,7 +81,7 @@ class InterfaceBulkCreateForm(
):
model = Interface
field_order = (
- 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
+ 'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
)
@@ -96,23 +98,23 @@ class RearPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = RearPort
- field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
+ field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay
- field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
-
+ field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
+ replication_fields = ('name', 'label', 'position')
position_pattern = ExpandableNameField(
- label='Position',
+ label=_('Position'),
required=False,
- help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')
)
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
model = DeviceBay
- field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
+ field_order = ('name', 'label', 'description', 'tags')
class InventoryItemBulkCreateForm(
@@ -121,6 +123,6 @@ class InventoryItemBulkCreateForm(
):
model = InventoryItem
field_order = (
- 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+ 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
)
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index 396f7e59b..38fa55738 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -1,6 +1,6 @@
from django import forms
-from django.utils.translation import gettext as _
from django.contrib.auth.models import User
+from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
from dcim.choices import *
@@ -54,6 +54,7 @@ __all__ = (
'SiteBulkEditForm',
'SiteGroupBulkEditForm',
'VirtualChassisBulkEditForm',
+ 'VirtualDeviceContextBulkEditForm'
)
@@ -125,24 +126,28 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
)
contact_email = forms.EmailField(
required=False,
- label='Contact E-mail'
- )
- description = forms.CharField(
- max_length=100,
- required=False
+ label=_('Contact E-mail')
)
time_zone = TimeZoneFormField(
choices=add_blank_choice(TimeZoneFormField().choices),
required=False,
widget=StaticSelect()
)
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = Site
fieldsets = (
(None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')),
)
nullable_fields = (
- 'region', 'group', 'tenant', 'asns', 'description', 'time_zone',
+ 'region', 'group', 'tenant', 'asns', 'time_zone', 'description', 'comments',
)
@@ -243,7 +248,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
serial = forms.CharField(
max_length=50,
required=False,
- label='Serial Number'
+ label=_('Serial Number')
)
asset_tag = forms.CharField(
max_length=50,
@@ -261,12 +266,12 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
)
u_height = forms.IntegerField(
required=False,
- label='Height (U)'
+ label=_('Height (U)')
)
desc_units = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
- label='Descending units'
+ label=_('Descending units')
)
outer_width = forms.IntegerField(
required=False,
@@ -281,6 +286,28 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False,
widget=StaticSelect()
)
+ mounting_depth = forms.IntegerField(
+ required=False,
+ min_value=1
+ )
+ weight = forms.DecimalField(
+ min_value=0,
+ required=False
+ )
+ max_weight = forms.IntegerField(
+ min_value=0,
+ required=False
+ )
+ weight_unit = forms.ChoiceField(
+ choices=add_blank_choice(WeightUnitChoices),
+ required=False,
+ initial='',
+ widget=StaticSelect()
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
comments = CommentField(
widget=SmallTextarea,
label='Comments'
@@ -288,12 +315,16 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
model = Rack
fieldsets = (
- ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')),
+ ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag', 'description')),
('Location', ('region', 'site_group', 'site', 'location')),
- ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')),
+ ('Hardware', (
+ 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
+ )),
+ ('Weight', ('weight', 'max_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', 'weight',
+ 'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -310,14 +341,19 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
description = forms.CharField(
- max_length=100,
+ max_length=200,
required=False
)
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = RackReservation
fieldsets = (
(None, ('user', 'tenant', 'description')),
)
+ nullable_fields = ('comments',)
class ManufacturerBulkEditForm(NetBoxModelBulkEditForm):
@@ -348,19 +384,38 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
is_full_depth = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect(),
- label='Is full depth'
+ label=_('Is full depth')
)
airflow = forms.ChoiceField(
choices=add_blank_choice(DeviceAirflowChoices),
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()
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = DeviceType
fieldsets = (
- (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')),
+ ('Device Type', ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow', 'description')),
+ ('Weight', ('weight', 'weight_unit')),
)
- nullable_fields = ('part_number', 'airflow')
+ nullable_fields = ('part_number', 'airflow', 'weight', 'weight_unit', 'description', 'comments')
class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
@@ -371,12 +426,31 @@ 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()
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = ModuleType
fieldsets = (
- (None, ('manufacturer', 'part_number')),
+ ('Module Type', ('manufacturer', 'part_number', 'description')),
+ ('Weight', ('weight', 'weight_unit')),
)
- nullable_fields = ('part_number',)
+ nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')
class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
@@ -386,7 +460,7 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
vm_role = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
- label='VM role'
+ label=_('VM role')
)
description = forms.CharField(
max_length=200,
@@ -470,17 +544,25 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
serial = forms.CharField(
max_length=50,
required=False,
- label='Serial Number'
+ label=_('Serial Number')
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
)
model = Device
fieldsets = (
- ('Device', ('device_role', 'status', 'tenant', 'platform')),
+ ('Device', ('device_role', 'status', 'tenant', 'platform', 'description')),
('Location', ('site', 'location')),
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
)
nullable_fields = (
- 'location', 'tenant', 'platform', 'serial', 'airflow',
+ 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
)
@@ -496,17 +578,31 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
'manufacturer_id': '$manufacturer'
}
)
+ status = forms.ChoiceField(
+ choices=add_blank_choice(ModuleStatusChoices),
+ required=False,
+ initial='',
+ widget=StaticSelect()
+ )
serial = forms.CharField(
max_length=50,
required=False,
- label='Serial Number'
+ label=_('Serial Number')
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
)
model = Module
fieldsets = (
- (None, ('manufacturer', 'module_type', 'serial')),
+ (None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
)
- nullable_fields = ('serial',)
+ nullable_fields = ('serial', 'description', 'comments')
class CableBulkEditForm(NetBoxModelBulkEditForm):
@@ -543,39 +639,44 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
initial='',
widget=StaticSelect()
)
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = Cable
fieldsets = (
- (None, ('type', 'status', 'tenant', 'label')),
+ (None, ('type', 'status', 'tenant', 'label', 'description')),
('Attributes', ('color', 'length', 'length_unit')),
)
nullable_fields = (
- 'type', 'status', 'tenant', 'label', 'color', 'length',
+ 'type', 'status', 'tenant', 'label', 'color', 'length', 'description', 'comments',
)
- 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(
max_length=30,
required=False
)
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = VirtualChassis
fieldsets = (
- (None, ('domain',)),
+ (None, ('domain', 'description')),
)
- nullable_fields = ('domain',)
+ nullable_fields = ('domain', 'description', 'comments')
class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
@@ -608,12 +709,20 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
'site_id': '$site'
}
)
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+ comments = CommentField(
+ widget=SmallTextarea,
+ label='Comments'
+ )
model = PowerPanel
fieldsets = (
- (None, ('region', 'site_group', 'site', 'location')),
+ (None, ('region', 'site_group', 'site', 'location', 'description')),
)
- nullable_fields = ('location',)
+ nullable_fields = ('location', 'description', 'comments')
class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
@@ -662,17 +771,21 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect
)
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
comments = CommentField(
widget=SmallTextarea,
- label='Comments'
+ label=_('Comments')
)
model = PowerFeed
fieldsets = (
- (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')),
+ (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')),
('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization'))
)
- nullable_fields = ('location', 'comments')
+ nullable_fields = ('location', 'description', 'comments')
#
@@ -735,12 +848,12 @@ class PowerPortTemplateBulkEditForm(BulkEditForm):
maximum_draw = forms.IntegerField(
min_value=1,
required=False,
- help_text="Maximum power draw (watts)"
+ help_text=_("Maximum power draw (watts)")
)
allocated_draw = forms.IntegerField(
min_value=1,
required=False,
- help_text="Allocated power draw (watts)"
+ help_text=_("Allocated power draw (watts)")
)
description = forms.CharField(
required=False
@@ -813,7 +926,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
- label='Management only'
+ label=_('Management only')
)
description = forms.CharField(
required=False
@@ -823,14 +936,14 @@ class InterfaceTemplateBulkEditForm(BulkEditForm):
required=False,
initial='',
widget=StaticSelect(),
- label='PoE mode'
+ label=_('PoE mode')
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
- label='PoE type'
+ label=_('PoE type')
)
nullable_fields = ('label', 'description', 'poe_mode', 'poe_type')
@@ -1071,31 +1184,31 @@ class InterfaceBulkEditForm(
query_params={
'type': 'lag',
},
- label='LAG'
+ label=_('LAG')
)
speed = forms.IntegerField(
required=False,
widget=SelectSpeedWidget(),
- label='Speed'
+ label=_('Speed')
)
mgmt_only = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
- label='Management only'
+ label=_('Management only')
)
poe_mode = forms.ChoiceField(
choices=add_blank_choice(InterfacePoEModeChoices),
required=False,
initial='',
widget=StaticSelect(),
- label='PoE mode'
+ label=_('PoE mode')
)
poe_type = forms.ChoiceField(
choices=add_blank_choice(InterfacePoETypeChoices),
required=False,
initial='',
widget=StaticSelect(),
- label='PoE type'
+ label=_('PoE type')
)
mark_connected = forms.NullBooleanField(
required=False,
@@ -1110,7 +1223,7 @@ class InterfaceBulkEditForm(
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
- label='VLAN group'
+ label=_('VLAN group')
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
@@ -1118,7 +1231,7 @@ class InterfaceBulkEditForm(
query_params={
'group_id': '$vlan_group',
},
- label='Untagged VLAN'
+ label=_('Untagged VLAN')
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
@@ -1126,12 +1239,12 @@ class InterfaceBulkEditForm(
query_params={
'group_id': '$vlan_group',
},
- label='Tagged VLANs'
+ label=_('Tagged VLANs')
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
- label='VRF'
+ label=_('VRF')
)
model = Interface
@@ -1218,7 +1331,7 @@ class FrontPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
- nullable_fields = ('module', 'label', 'description')
+ nullable_fields = ('module', 'label', 'description', 'color')
class RearPortBulkEditForm(
@@ -1229,7 +1342,7 @@ class RearPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
- nullable_fields = ('module', 'label', 'description')
+ nullable_fields = ('module', 'label', 'description', 'color')
class ModuleBayBulkEditForm(
@@ -1296,3 +1409,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
(None, ('color', 'description')),
)
nullable_fields = ('color', 'description')
+
+
+class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False
+ )
+ status = forms.ChoiceField(
+ required=False,
+ choices=add_blank_choice(VirtualDeviceContextStatusChoices),
+ widget=StaticSelect()
+ )
+ tenant = DynamicModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False
+ )
+ model = VirtualDeviceContext
+ fieldsets = (
+ (None, ('device', 'status', 'tenant')),
+ )
+ nullable_fields = ('device', 'tenant', )
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index f0fd9bf86..bdbaf9f18 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -3,68 +3,73 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import VRF
-from netbox.forms import NetBoxModelCSVForm
+from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
+from .common import ModuleCommonForm
__all__ = (
- 'CableCSVForm',
- 'ChildDeviceCSVForm',
- 'ConsolePortCSVForm',
- 'ConsoleServerPortCSVForm',
- 'DeviceBayCSVForm',
- 'DeviceCSVForm',
- 'DeviceRoleCSVForm',
- 'FrontPortCSVForm',
- 'InterfaceCSVForm',
- 'InventoryItemCSVForm',
- 'InventoryItemRoleCSVForm',
- 'LocationCSVForm',
- 'ManufacturerCSVForm',
- 'ModuleCSVForm',
- 'ModuleBayCSVForm',
- 'PlatformCSVForm',
- 'PowerFeedCSVForm',
- 'PowerOutletCSVForm',
- 'PowerPanelCSVForm',
- 'PowerPortCSVForm',
- 'RackCSVForm',
- 'RackReservationCSVForm',
- 'RackRoleCSVForm',
- 'RearPortCSVForm',
- 'RegionCSVForm',
- 'SiteCSVForm',
- 'SiteGroupCSVForm',
- 'VirtualChassisCSVForm',
+ 'CableImportForm',
+ 'ChildDeviceImportForm',
+ 'ConsolePortImportForm',
+ 'ConsoleServerPortImportForm',
+ 'DeviceBayImportForm',
+ 'DeviceImportForm',
+ 'DeviceRoleImportForm',
+ 'DeviceTypeImportForm',
+ 'FrontPortImportForm',
+ 'InterfaceImportForm',
+ 'InventoryItemImportForm',
+ 'InventoryItemRoleImportForm',
+ 'LocationImportForm',
+ 'ManufacturerImportForm',
+ 'ModuleImportForm',
+ 'ModuleBayImportForm',
+ 'ModuleTypeImportForm',
+ 'PlatformImportForm',
+ 'PowerFeedImportForm',
+ 'PowerOutletImportForm',
+ 'PowerPanelImportForm',
+ 'PowerPortImportForm',
+ 'RackImportForm',
+ 'RackReservationImportForm',
+ 'RackRoleImportForm',
+ 'RearPortImportForm',
+ 'RegionImportForm',
+ 'SiteImportForm',
+ 'SiteGroupImportForm',
+ 'VirtualChassisImportForm',
+ 'VirtualDeviceContextImportForm'
)
-class RegionCSVForm(NetBoxModelCSVForm):
+class RegionImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of parent region'
+ help_text=_('Name of parent region')
)
class Meta:
model = Region
- fields = ('name', 'slug', 'parent', 'description')
+ fields = ('name', 'slug', 'parent', 'description', 'tags')
-class SiteGroupCSVForm(NetBoxModelCSVForm):
+class SiteGroupImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of parent site group'
+ help_text=_('Name of parent site group')
)
class Meta:
@@ -72,86 +77,86 @@ class SiteGroupCSVForm(NetBoxModelCSVForm):
fields = ('name', 'slug', 'parent', 'description')
-class SiteCSVForm(NetBoxModelCSVForm):
+class SiteImportForm(NetBoxModelImportForm):
status = CSVChoiceField(
choices=SiteStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
region = CSVModelChoiceField(
queryset=Region.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned region'
+ help_text=_('Assigned region')
)
group = CSVModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned group'
+ help_text=_('Assigned group')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
class Meta:
model = Site
fields = (
'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description',
- 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
+ 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags'
)
help_texts = {
'time_zone': mark_safe(
- 'Time zone (available options)'
+ _('Time zone (available options)')
)
}
-class LocationCSVForm(NetBoxModelCSVForm):
+class LocationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
- help_text='Assigned site'
+ help_text=_('Assigned site')
)
parent = CSVModelChoiceField(
queryset=Location.objects.all(),
required=False,
to_field_name='name',
- help_text='Parent location',
+ help_text=_('Parent location'),
error_messages={
- 'invalid_choice': 'Location not found.',
+ 'invalid_choice': _('Location not found.'),
}
)
status = CSVChoiceField(
choices=LocationStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
class Meta:
model = Location
- fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description')
+ fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
-class RackRoleCSVForm(NetBoxModelCSVForm):
+class RackRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = RackRole
- fields = ('name', 'slug', 'color', 'description')
+ fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
- 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
+ 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
}
-class RackCSVForm(NetBoxModelCSVForm):
+class RackImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name'
@@ -165,38 +170,44 @@ class RackCSVForm(NetBoxModelCSVForm):
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of assigned tenant'
+ help_text=_('Name of assigned tenant')
)
status = CSVChoiceField(
choices=RackStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
role = CSVModelChoiceField(
queryset=RackRole.objects.all(),
required=False,
to_field_name='name',
- help_text='Name of assigned role'
+ help_text=_('Name of assigned role')
)
type = CSVChoiceField(
choices=RackTypeChoices,
required=False,
- help_text='Rack type'
+ help_text=_('Rack type')
)
width = forms.ChoiceField(
choices=RackWidthChoices,
- help_text='Rail-to-rail width (in inches)'
+ help_text=_('Rail-to-rail width (in inches)')
)
outer_unit = CSVChoiceField(
choices=RackDimensionUnitChoices,
required=False,
- help_text='Unit for outer dimensions'
+ help_text=_('Unit for outer dimensions')
+ )
+ weight_unit = CSVChoiceField(
+ choices=WeightUnitChoices,
+ required=False,
+ help_text=_('Unit for rack weights')
)
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
- 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
+ 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
+ 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -209,38 +220,38 @@ class RackCSVForm(NetBoxModelCSVForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-class RackReservationCSVForm(NetBoxModelCSVForm):
+class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
- help_text='Parent site'
+ help_text=_('Parent site')
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
- help_text="Rack's location (if any)"
+ help_text=_("Rack's location (if any)")
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
- help_text='Rack'
+ help_text=_('Rack')
)
units = SimpleArrayField(
base_field=forms.IntegerField(),
required=True,
- help_text='Comma-separated list of individual unit numbers'
+ help_text=_('Comma-separated list of individual unit numbers')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
class Meta:
model = RackReservation
- fields = ('site', 'location', 'rack', 'units', 'tenant', 'description')
+ fields = ('site', 'location', 'rack', 'units', 'tenant', 'description', 'comments', 'tags')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@@ -259,81 +270,106 @@ class RackReservationCSVForm(NetBoxModelCSVForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-class ManufacturerCSVForm(NetBoxModelCSVForm):
+class ManufacturerImportForm(NetBoxModelImportForm):
class Meta:
model = Manufacturer
- fields = ('name', 'slug', 'description')
+ fields = ('name', 'slug', 'description', 'tags')
-class DeviceRoleCSVForm(NetBoxModelCSVForm):
+class DeviceTypeImportForm(NetBoxModelImportForm):
+ manufacturer = forms.ModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name='name'
+ )
+
+ class Meta:
+ model = DeviceType
+ fields = [
+ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+ 'description', 'comments',
+ ]
+
+
+class ModuleTypeImportForm(NetBoxModelImportForm):
+ manufacturer = forms.ModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name='name'
+ )
+
+ class Meta:
+ model = ModuleType
+ fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
+
+
+class DeviceRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = DeviceRole
- fields = ('name', 'slug', 'color', 'vm_role', 'description')
+ fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags')
help_texts = {
- 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
+ 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
}
-class PlatformCSVForm(NetBoxModelCSVForm):
+class PlatformImportForm(NetBoxModelImportForm):
slug = SlugField()
manufacturer = CSVModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
to_field_name='name',
- help_text='Limit platform assignments to this manufacturer'
+ help_text=_('Limit platform assignments to this manufacturer')
)
class Meta:
model = Platform
- fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description')
+ fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags')
-class BaseDeviceCSVForm(NetBoxModelCSVForm):
+class BaseDeviceImportForm(NetBoxModelImportForm):
device_role = CSVModelChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='name',
- help_text='Assigned role'
+ help_text=_('Assigned role')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
manufacturer = CSVModelChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='name',
- help_text='Device type manufacturer'
+ help_text=_('Device type manufacturer')
)
device_type = CSVModelChoiceField(
queryset=DeviceType.objects.all(),
to_field_name='model',
- help_text='Device type model'
+ help_text=_('Device type model')
)
platform = CSVModelChoiceField(
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned platform'
+ help_text=_('Assigned platform')
)
status = CSVChoiceField(
choices=DeviceStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
virtual_chassis = CSVModelChoiceField(
queryset=VirtualChassis.objects.all(),
to_field_name='name',
required=False,
- help_text='Virtual chassis'
+ help_text=_('Virtual chassis')
)
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
- help_text='Virtualization cluster'
+ help_text=_('Virtualization cluster')
)
class Meta:
@@ -354,40 +390,40 @@ class BaseDeviceCSVForm(NetBoxModelCSVForm):
self.fields['device_type'].queryset = self.fields['device_type'].queryset.filter(**params)
-class DeviceCSVForm(BaseDeviceCSVForm):
+class DeviceImportForm(BaseDeviceImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
- help_text='Assigned site'
+ help_text=_('Assigned site')
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
- help_text="Assigned location (if any)"
+ help_text=_("Assigned location (if any)")
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
- help_text="Assigned rack (if any)"
+ help_text=_("Assigned rack (if any)")
)
face = CSVChoiceField(
choices=DeviceFaceChoices,
required=False,
- help_text='Mounted rack face'
+ help_text=_('Mounted rack face')
)
airflow = CSVChoiceField(
choices=DeviceAirflowChoices,
required=False,
- help_text='Airflow direction'
+ help_text=_('Airflow direction')
)
- class Meta(BaseDeviceCSVForm.Meta):
+ class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
- 'cluster', 'comments',
+ 'cluster', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -407,24 +443,40 @@ class DeviceCSVForm(BaseDeviceCSVForm):
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
-class ModuleCSVForm(NetBoxModelCSVForm):
+class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
- to_field_name='name'
+ to_field_name='name',
+ help_text=_('The device in which this module is installed')
)
module_bay = CSVModelChoiceField(
queryset=ModuleBay.objects.all(),
- to_field_name='name'
+ to_field_name='name',
+ help_text=_('The module bay in which this module is installed')
)
module_type = CSVModelChoiceField(
queryset=ModuleType.objects.all(),
- to_field_name='model'
+ to_field_name='model',
+ help_text=_('The type of module')
+ )
+ status = CSVChoiceField(
+ choices=ModuleStatusChoices,
+ help_text=_('Operational status')
+ )
+ replicate_components = forms.BooleanField(
+ required=False,
+ help_text=_('Automatically populate components associated with this module type (enabled by default)')
+ )
+ adopt_components = forms.BooleanField(
+ required=False,
+ help_text=_('Adopt already existing components')
)
class Meta:
model = Module
fields = (
- 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments',
+ 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
+ 'replicate_components', 'adopt_components', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -435,23 +487,30 @@ class ModuleCSVForm(NetBoxModelCSVForm):
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
+ def clean_replicate_components(self):
+ # Make sure replicate_components is True when it's not included in the uploaded data
+ if 'replicate_components' not in self.data:
+ return True
+ else:
+ return self.cleaned_data['replicate_components']
-class ChildDeviceCSVForm(BaseDeviceCSVForm):
+
+class ChildDeviceImportForm(BaseDeviceImportForm):
parent = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
- help_text='Parent device'
+ help_text=_('Parent device')
)
device_bay = CSVModelChoiceField(
queryset=DeviceBay.objects.all(),
to_field_name='name',
- help_text='Device bay in which this device is installed'
+ help_text=_('Device bay in which this device is installed')
)
- class Meta(BaseDeviceCSVForm.Meta):
+ class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
- 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
+ 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags'
]
def __init__(self, data=None, *args, **kwargs):
@@ -482,7 +541,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
# Device components
#
-class ConsolePortCSVForm(NetBoxModelCSVForm):
+class ConsolePortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -490,22 +549,22 @@ class ConsolePortCSVForm(NetBoxModelCSVForm):
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
- help_text='Port type'
+ help_text=_('Port type')
)
speed = CSVTypedChoiceField(
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
required=False,
- help_text='Port speed in bps'
+ help_text=_('Port speed in bps')
)
class Meta:
model = ConsolePort
- fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+ fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags')
-class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
+class ConsoleServerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -513,22 +572,22 @@ class ConsoleServerPortCSVForm(NetBoxModelCSVForm):
type = CSVChoiceField(
choices=ConsolePortTypeChoices,
required=False,
- help_text='Port type'
+ help_text=_('Port type')
)
speed = CSVTypedChoiceField(
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
required=False,
- help_text='Port speed in bps'
+ help_text=_('Port speed in bps')
)
class Meta:
model = ConsoleServerPort
- fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description')
+ fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags')
-class PowerPortCSVForm(NetBoxModelCSVForm):
+class PowerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -536,17 +595,17 @@ class PowerPortCSVForm(NetBoxModelCSVForm):
type = CSVChoiceField(
choices=PowerPortTypeChoices,
required=False,
- help_text='Port type'
+ help_text=_('Port type')
)
class Meta:
model = PowerPort
fields = (
- 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
+ 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', 'tags'
)
-class PowerOutletCSVForm(NetBoxModelCSVForm):
+class PowerOutletImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -554,29 +613,29 @@ class PowerOutletCSVForm(NetBoxModelCSVForm):
type = CSVChoiceField(
choices=PowerOutletTypeChoices,
required=False,
- help_text='Outlet type'
+ help_text=_('Outlet type')
)
power_port = CSVModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
to_field_name='name',
- help_text='Local power port which feeds this outlet'
+ help_text=_('Local power port which feeds this outlet')
)
feed_leg = CSVChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
- help_text='Electrical phase (for three-phase circuits)'
+ help_text=_('Electrical phase (for three-phase circuits)')
)
class Meta:
model = PowerOutlet
- fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description')
+ fields = ('device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description', 'tags')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit PowerPort choices to those belonging to this device (or VC master)
- if self.is_bound:
+ if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
@@ -595,7 +654,7 @@ class PowerOutletCSVForm(NetBoxModelCSVForm):
self.fields['power_port'].queryset = PowerPort.objects.none()
-class InterfaceCSVForm(NetBoxModelCSVForm):
+class InterfaceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -604,23 +663,23 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
- help_text='Parent interface'
+ help_text=_('Parent interface')
)
bridge = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
- help_text='Bridged interface'
+ help_text=_('Bridged interface')
)
lag = CSVModelChoiceField(
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
- help_text='Parent LAG interface'
+ help_text=_('Parent LAG interface')
)
type = CSVChoiceField(
choices=InterfaceTypeChoices,
- help_text='Physical medium'
+ help_text=_('Physical medium')
)
duplex = CSVChoiceField(
choices=InterfaceDuplexChoices,
@@ -629,28 +688,28 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
poe_mode = CSVChoiceField(
choices=InterfacePoEModeChoices,
required=False,
- help_text='PoE mode'
+ help_text=_('PoE mode')
)
poe_type = CSVChoiceField(
choices=InterfacePoETypeChoices,
required=False,
- help_text='PoE type'
+ help_text=_('PoE type')
)
mode = CSVChoiceField(
choices=InterfaceModeChoices,
required=False,
- help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
+ help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
)
vrf = CSVModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
- help_text='Assigned VRF'
+ help_text=_('Assigned VRF')
)
rf_role = CSVChoiceField(
choices=WirelessRoleChoices,
required=False,
- help_text='Wireless role (AP/station)'
+ help_text=_('Wireless role (AP/station)')
)
class Meta:
@@ -658,7 +717,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
- 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+ 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
)
def __init__(self, data=None, *args, **kwargs):
@@ -682,7 +741,7 @@ class InterfaceCSVForm(NetBoxModelCSVForm):
return self.cleaned_data['enabled']
-class FrontPortCSVForm(NetBoxModelCSVForm):
+class FrontPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -690,28 +749,28 @@ class FrontPortCSVForm(NetBoxModelCSVForm):
rear_port = CSVModelChoiceField(
queryset=RearPort.objects.all(),
to_field_name='name',
- help_text='Corresponding rear port'
+ help_text=_('Corresponding rear port')
)
type = CSVChoiceField(
choices=PortTypeChoices,
- help_text='Physical medium classification'
+ help_text=_('Physical medium classification')
)
class Meta:
model = FrontPort
fields = (
'device', 'name', 'label', 'type', 'color', 'mark_connected', 'rear_port', 'rear_port_position',
- 'description',
+ 'description', 'tags'
)
help_texts = {
- 'rear_port_position': 'Mapped position on corresponding rear port',
+ 'rear_port_position': _('Mapped position on corresponding rear port'),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to those belonging to this device (or VC master)
- if self.is_bound:
+ if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
@@ -730,25 +789,25 @@ class FrontPortCSVForm(NetBoxModelCSVForm):
self.fields['rear_port'].queryset = RearPort.objects.none()
-class RearPortCSVForm(NetBoxModelCSVForm):
+class RearPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
- help_text='Physical medium classification',
+ help_text=_('Physical medium classification'),
choices=PortTypeChoices,
)
class Meta:
model = RearPort
- fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description')
+ fields = ('device', 'name', 'label', 'type', 'color', 'mark_connected', 'positions', 'description', 'tags')
help_texts = {
- 'positions': 'Number of front ports which may be mapped'
+ 'positions': _('Number of front ports which may be mapped')
}
-class ModuleBayCSVForm(NetBoxModelCSVForm):
+class ModuleBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -756,10 +815,10 @@ class ModuleBayCSVForm(NetBoxModelCSVForm):
class Meta:
model = ModuleBay
- fields = ('device', 'name', 'label', 'position', 'description')
+ fields = ('device', 'name', 'label', 'position', 'description', 'tags')
-class DeviceBayCSVForm(NetBoxModelCSVForm):
+class DeviceBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -768,21 +827,21 @@ class DeviceBayCSVForm(NetBoxModelCSVForm):
queryset=Device.objects.all(),
required=False,
to_field_name='name',
- help_text='Child device installed within this bay',
+ help_text=_('Child device installed within this bay'),
error_messages={
- 'invalid_choice': 'Child device not found.',
+ 'invalid_choice': _('Child device not found.'),
}
)
class Meta:
model = DeviceBay
- fields = ('device', 'name', 'label', 'installed_device', 'description')
+ fields = ('device', 'name', 'label', 'installed_device', 'description', 'tags')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit installed device choices to devices of the correct type and location
- if self.is_bound:
+ if self.is_bound and 'device' in self.data:
try:
device = self.fields['device'].to_python(self.data['device'])
except forms.ValidationError:
@@ -805,7 +864,7 @@ class DeviceBayCSVForm(NetBoxModelCSVForm):
self.fields['installed_device'].queryset = Interface.objects.none()
-class InventoryItemCSVForm(NetBoxModelCSVForm):
+class InventoryItemImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
@@ -824,14 +883,24 @@ class InventoryItemCSVForm(NetBoxModelCSVForm):
queryset=Device.objects.all(),
to_field_name='name',
required=False,
- help_text='Parent inventory item'
+ help_text=_('Parent inventory item')
+ )
+ component_type = CSVContentTypeField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=MODULAR_COMPONENT_MODELS,
+ required=False,
+ help_text=_('Component Type')
+ )
+ component_name = forms.CharField(
+ required=False,
+ help_text=_('Component Name')
)
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
- 'description',
+ 'description', 'tags', 'component_type', 'component_name',
)
def __init__(self, *args, **kwargs):
@@ -849,19 +918,37 @@ class InventoryItemCSVForm(NetBoxModelCSVForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
+ def clean_component_name(self):
+ content_type = self.cleaned_data.get('component_type')
+ component_name = self.cleaned_data.get('component_name')
+ device = self.cleaned_data.get("device")
+
+ if not device and hasattr(self, 'instance'):
+ device = self.instance.device
+
+ if not all([device, content_type, component_name]):
+ return None
+
+ model = content_type.model_class()
+ try:
+ component = model.objects.get(device=device, name=component_name)
+ self.instance.component = component
+ except ObjectDoesNotExist:
+ raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+
#
# Device component roles
#
-class InventoryItemRoleCSVForm(NetBoxModelCSVForm):
+class InventoryItemRoleImportForm(NetBoxModelImportForm):
slug = SlugField()
class Meta:
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
- 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
+ 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
}
@@ -869,68 +956,68 @@ class InventoryItemRoleCSVForm(NetBoxModelCSVForm):
# Cables
#
-class CableCSVForm(NetBoxModelCSVForm):
+class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
- help_text='Side A device'
+ help_text=_('Side A device')
)
side_a_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text='Side A type'
+ help_text=_('Side A type')
)
side_a_name = forms.CharField(
- help_text='Side A component name'
+ help_text=_('Side A component name')
)
# Termination B
side_b_device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
- help_text='Side B device'
+ help_text=_('Side B device')
)
side_b_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
- help_text='Side B type'
+ help_text=_('Side B type')
)
side_b_name = forms.CharField(
- help_text='Side B component name'
+ help_text=_('Side B component name')
)
# Cable attributes
status = CSVChoiceField(
choices=LinkStatusChoices,
required=False,
- help_text='Connection status'
+ help_text=_('Connection status')
)
type = CSVChoiceField(
choices=CableTypeChoices,
required=False,
- help_text='Physical medium classification'
+ help_text=_('Physical medium classification')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
- help_text='Assigned tenant'
+ help_text=_('Assigned tenant')
)
length_unit = CSVChoiceField(
choices=CableLengthUnitChoices,
required=False,
- help_text='Length unit'
+ help_text=_('Length unit')
)
class Meta:
model = Cable
fields = [
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
- 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
+ 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
- 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00
)'),
+ 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00
)')),
}
def _clean_side(self, side):
@@ -974,28 +1061,28 @@ class CableCSVForm(NetBoxModelCSVForm):
# Virtual chassis
#
-class VirtualChassisCSVForm(NetBoxModelCSVForm):
+class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
- help_text='Master device'
+ help_text=_('Master device')
)
class Meta:
model = VirtualChassis
- fields = ('name', 'domain', 'master')
+ fields = ('name', 'domain', 'master', 'description', 'comments', 'tags')
#
# Power
#
-class PowerPanelCSVForm(NetBoxModelCSVForm):
+class PowerPanelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
- help_text='Name of parent site'
+ help_text=_('Name of parent site')
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
@@ -1005,7 +1092,7 @@ class PowerPanelCSVForm(NetBoxModelCSVForm):
class Meta:
model = PowerPanel
- fields = ('site', 'location', 'name')
+ fields = ('site', 'location', 'name', 'description', 'comments', 'tags')
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
@@ -1017,51 +1104,51 @@ class PowerPanelCSVForm(NetBoxModelCSVForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
-class PowerFeedCSVForm(NetBoxModelCSVForm):
+class PowerFeedImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
- help_text='Assigned site'
+ help_text=_('Assigned site')
)
power_panel = CSVModelChoiceField(
queryset=PowerPanel.objects.all(),
to_field_name='name',
- help_text='Upstream power panel'
+ help_text=_('Upstream power panel')
)
location = CSVModelChoiceField(
queryset=Location.objects.all(),
to_field_name='name',
required=False,
- help_text="Rack's location (if any)"
+ help_text=_("Rack's location (if any)")
)
rack = CSVModelChoiceField(
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
- help_text='Rack'
+ help_text=_('Rack')
)
status = CSVChoiceField(
choices=PowerFeedStatusChoices,
- help_text='Operational status'
+ help_text=_('Operational status')
)
type = CSVChoiceField(
choices=PowerFeedTypeChoices,
- help_text='Primary or redundant'
+ help_text=_('Primary or redundant')
)
supply = CSVChoiceField(
choices=PowerFeedSupplyChoices,
- help_text='Supply type (AC/DC)'
+ help_text=_('Supply type (AC/DC)')
)
phase = CSVChoiceField(
choices=PowerFeedPhaseChoices,
- help_text='Single or three-phase'
+ help_text=_('Single or three-phase')
)
class Meta:
model = PowerFeed
fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
- 'voltage', 'amperage', 'max_utilization', 'comments',
+ 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -1083,3 +1170,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm):
f"location__{self.fields['location'].to_field_name}": data.get('location'),
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
+
+
+class VirtualDeviceContextImportForm(NetBoxModelImportForm):
+
+ device = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ help_text='Assigned role'
+ )
+ tenant = CSVModelChoiceField(
+ queryset=Tenant.objects.all(),
+ required=False,
+ to_field_name='name',
+ help_text='Assigned tenant'
+ )
+
+ class Meta:
+ fields = [
+ 'name', 'device', 'status', 'tenant', 'identifier', 'comments',
+ ]
+ model = VirtualDeviceContext
+ help_texts = {}
diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py
index f484b48e1..a2243ce2d 100644
--- a/netbox/dcim/forms/common.py
+++ b/netbox/dcim/forms/common.py
@@ -1,10 +1,12 @@
from django import forms
+from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
__all__ = (
'InterfaceCommonForm',
+ 'ModuleCommonForm'
)
@@ -12,13 +14,13 @@ class InterfaceCommonForm(forms.Form):
mac_address = forms.CharField(
empty_value=None,
required=False,
- label='MAC address'
+ label=_('MAC address')
)
mtu = forms.IntegerField(
required=False,
min_value=INTERFACE_MTU_MIN,
max_value=INTERFACE_MTU_MAX,
- label='MTU'
+ label=_('MTU')
)
def clean(self):
@@ -47,3 +49,62 @@ class InterfaceCommonForm(forms.Form):
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
f"the interface's parent device/VM, or they must be global"
})
+
+
+class ModuleCommonForm(forms.Form):
+
+ def clean(self):
+ super().clean()
+
+ replicate_components = self.cleaned_data.get('replicate_components')
+ adopt_components = self.cleaned_data.get('adopt_components')
+ device = self.cleaned_data.get('device')
+ module_type = self.cleaned_data.get('module_type')
+ module_bay = self.cleaned_data.get('module_bay')
+
+ if adopt_components:
+ self.instance._adopt_components = True
+
+ # Bail out if we are not installing a new module or if we are not replicating components (or if
+ # validation has already failed)
+ if self.errors or self.instance.pk or not replicate_components:
+ self.instance._disable_replication = True
+ return
+
+ for templates, component_attribute in [
+ ("consoleporttemplates", "consoleports"),
+ ("consoleserverporttemplates", "consoleserverports"),
+ ("interfacetemplates", "interfaces"),
+ ("powerporttemplates", "powerports"),
+ ("poweroutlettemplates", "poweroutlets"),
+ ("rearporttemplates", "rearports"),
+ ("frontporttemplates", "frontports")
+ ]:
+ # Prefetch installed components
+ installed_components = {
+ component.name: component for component in getattr(device, component_attribute).all()
+ }
+
+ # Get the templates for the module type.
+ for template in getattr(module_type, templates).all():
+ # Installing modules with placeholders require that the bay has a position value
+ if MODULE_TOKEN in template.name and not module_bay.position:
+ raise forms.ValidationError(
+ "Cannot install module with placeholder values in a module bay with no position defined"
+ )
+
+ resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
+ existing_item = installed_components.get(resolved_name)
+
+ # It is not possible to adopt components already belonging to a module
+ if adopt_components and existing_item and existing_item.module:
+ raise forms.ValidationError(
+ f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
+ f"to a module"
+ )
+
+ # If we are not adopting components we error if the component exists
+ if not adopt_components and resolved_name in installed_components:
+ raise forms.ValidationError(
+ f"{template.component_model.__name__} - {resolved_name} already exists"
+ )
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index cc5cf362f..ba5e51c41 100644
--- a/netbox/dcim/forms/connections.py
+++ b/netbox/dcim/forms/connections.py
@@ -1,9 +1,10 @@
from django import forms
+from django.utils.translation import gettext as _
from circuits.models import Circuit, CircuitTermination, Provider
from dcim.models import *
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
-from .models import CableForm
+from .model_forms import CableForm
def get_cable_form(a_type, b_type):
@@ -16,7 +17,7 @@ def get_cable_form(a_type, b_type):
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
queryset=Region.objects.all(),
- label='Region',
+ label=_('Region'),
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
@@ -24,7 +25,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
- label='Site group',
+ label=_('Site group'),
required=False,
initial_params={
'sites': f'$termination_{cable_end}_site'
@@ -32,7 +33,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
queryset=Site.objects.all(),
- label='Site',
+ label=_('Site'),
required=False,
query_params={
'region_id': f'$termination_{cable_end}_region',
@@ -41,7 +42,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
queryset=Location.objects.all(),
- label='Location',
+ label=_('Location'),
required=False,
null_option='None',
query_params={
@@ -54,7 +55,7 @@ def get_cable_form(a_type, b_type):
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
queryset=Rack.objects.all(),
- label='Rack',
+ label=_('Rack'),
required=False,
null_option='None',
initial_params={
@@ -67,7 +68,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
queryset=Device.objects.all(),
- label='Device',
+ label=_('Device'),
required=False,
initial_params={
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
@@ -93,7 +94,7 @@ def get_cable_form(a_type, b_type):
attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(),
- label='Power Panel',
+ label=_('Power Panel'),
required=False,
initial_params={
'powerfeeds__in': f'${cable_end}_terminations'
@@ -105,10 +106,10 @@ def get_cable_form(a_type, b_type):
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
- label='Power Feed',
+ label=_('Power Feed'),
disabled_indicator='_occupied',
query_params={
- 'powerpanel_id': f'$termination_{cable_end}_powerpanel',
+ 'power_panel_id': f'$termination_{cable_end}_powerpanel',
}
)
@@ -117,7 +118,7 @@ def get_cable_form(a_type, b_type):
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
queryset=Provider.objects.all(),
- label='Provider',
+ label=_('Provider'),
initial_params={
'circuits': f'$termination_{cable_end}_circuit'
},
@@ -125,7 +126,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
queryset=Circuit.objects.all(),
- label='Circuit',
+ label=_('Circuit'),
initial_params={
'terminations__in': f'${cable_end}_terminations'
},
@@ -136,7 +137,7 @@ def get_cable_form(a_type, b_type):
)
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
queryset=term_cls.objects.all(),
- label='Side',
+ label=_('Side'),
disabled_indicator='_occupied',
query_params={
'circuit_id': f'$termination_{cable_end}_circuit',
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 173ea5d1e..815f0586a 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -50,6 +50,7 @@ __all__ = (
'SiteFilterForm',
'SiteGroupFilterForm',
'VirtualChassisFilterForm',
+ 'VirtualDeviceContextFilterForm'
)
@@ -116,7 +117,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region
fieldsets = (
- (None, ('q', 'tag', 'parent_id')),
+ (None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -130,7 +131,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup
fieldsets = (
- (None, ('q', 'tag', 'parent_id')),
+ (None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
@@ -144,7 +145,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Site
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -174,7 +175,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Location
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
@@ -222,12 +223,13 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Rack
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
+ ('Weight', ('weight', 'max_weight', 'weight_unit')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -281,6 +283,18 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False
)
tag = TagFilterField(model)
+ weight = forms.DecimalField(
+ required=False,
+ min_value=1
+ )
+ max_weight = forms.IntegerField(
+ required=False,
+ min_value=1
+ )
+ weight_unit = forms.ChoiceField(
+ choices=add_blank_choice(WeightUnitChoices),
+ required=False
+ )
class RackElevationFilterForm(RackFilterForm):
@@ -298,7 +312,7 @@ class RackElevationFilterForm(RackFilterForm):
class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('User', ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -354,7 +368,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
)
tag = TagFilterField(model)
@@ -363,12 +377,14 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
model = DeviceType
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
+ ('Images', ('has_front_image', 'has_rear_image')),
('Components', (
'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(),
@@ -386,6 +402,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
+ has_front_image = forms.NullBooleanField(
+ required=False,
+ label='Has a front image',
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ has_rear_image = forms.NullBooleanField(
+ required=False,
+ label='Has a rear image',
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
@@ -450,17 +480,25 @@ 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):
model = ModuleType
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'part_number')),
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports',
)),
+ ('Weight', ('weight', 'weight_unit')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -514,6 +552,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):
@@ -539,7 +584,7 @@ class DeviceFilterForm(
):
model = Device
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
@@ -689,11 +734,41 @@ class DeviceFilterForm(
tag = TagFilterField(model)
+class VirtualDeviceContextFilterForm(
+ TenancyFilterForm,
+ NetBoxModelFilterSetForm
+):
+ model = VirtualDeviceContext
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ ('Attributes', ('device', 'status', 'has_primary_ip')),
+ ('Tenant', ('tenant_group_id', 'tenant_id')),
+ )
+ device = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ label=_('Device'),
+ fetch_trigger='open'
+ )
+ status = MultipleChoiceField(
+ required=False,
+ choices=add_blank_choice(VirtualDeviceContextStatusChoices)
+ )
+ has_primary_ip = forms.NullBooleanField(
+ required=False,
+ label='Has a primary IP',
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ tag = TagFilterField(model)
+
+
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
model = Module
fieldsets = (
- (None, ('q', 'tag')),
- ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
+ (None, ('q', 'filter_id', 'tag')),
+ ('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -710,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
label=_('Type'),
fetch_trigger='open'
)
+ status = MultipleChoiceField(
+ choices=ModuleStatusChoices,
+ required=False
+ )
serial = forms.CharField(
required=False
)
@@ -722,7 +801,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VirtualChassis
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
)
@@ -751,7 +830,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')),
@@ -823,7 +902,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = PowerPanel
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
)
@@ -861,7 +940,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
class PowerFeedFilterForm(NetBoxModelFilterSetForm):
model = PowerFeed
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
)
@@ -936,12 +1015,37 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
# Device components
#
-class ConsolePortFilterForm(DeviceComponentFilterForm):
+class CabledFilterForm(forms.Form):
+ cabled = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+ occupied = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class PathEndpointFilterForm(CabledFilterForm):
+ connected = forms.NullBooleanField(
+ required=False,
+ widget=StaticSelect(
+ choices=BOOLEAN_WITH_BLANK_CHOICES
+ )
+ )
+
+
+class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -954,12 +1058,13 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
+class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -972,12 +1077,13 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class PowerPortFilterForm(DeviceComponentFilterForm):
+class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=PowerPortTypeChoices,
@@ -986,12 +1092,13 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class PowerOutletFilterForm(DeviceComponentFilterForm):
+class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=PowerOutletTypeChoices,
@@ -1000,15 +1107,25 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class InterfaceFilterForm(DeviceComponentFilterForm):
+class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
- ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
+ 'device_id', 'vdc_id')),
+ ('Connection', ('cabled', 'connected', 'occupied')),
+ )
+ vdc_id = DynamicModelMultipleChoiceField(
+ queryset=VirtualDeviceContext.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device_id',
+ },
+ label=_('Virtual Device Context')
)
kind = MultipleChoiceField(
choices=InterfaceKindChoices,
@@ -1089,11 +1206,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class FrontPortFilterForm(DeviceComponentFilterForm):
+class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Cable', ('cabled', 'occupied')),
)
model = FrontPort
type = MultipleChoiceField(
@@ -1106,12 +1224,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
-class RearPortFilterForm(DeviceComponentFilterForm):
+class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
+ ('Cable', ('cabled', 'occupied')),
)
type = MultipleChoiceField(
choices=PortTypeChoices,
@@ -1126,7 +1245,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
@@ -1139,7 +1258,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
@@ -1149,7 +1268,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
- (None, ('q', 'tag')),
+ (None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/model_forms.py
similarity index 69%
rename from netbox/dcim/forms/models.py
rename to netbox/dcim/forms/model_forms.py
index a21265db4..91e0266f0 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -1,7 +1,7 @@
from django import forms
-from django.utils.translation import gettext as _
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
from dcim.choices import *
@@ -17,7 +17,7 @@ from utilities.forms import (
)
from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
-from .common import InterfaceCommonForm
+from .common import InterfaceCommonForm, ModuleCommonForm
__all__ = (
'CableForm',
@@ -62,6 +62,7 @@ __all__ = (
'SiteGroupForm',
'VCMemberSelectForm',
'VirtualChassisForm',
+ 'VirtualDeviceContextForm'
)
INTERFACE_MODE_HELP_TEXT = """
@@ -78,6 +79,12 @@ class RegionForm(NetBoxModelForm):
)
slug = SlugField()
+ fieldsets = (
+ ('Region', (
+ 'parent', 'name', 'slug', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = Region
fields = (
@@ -92,6 +99,12 @@ class SiteGroupForm(NetBoxModelForm):
)
slug = SlugField()
+ fieldsets = (
+ ('Site Group', (
+ 'parent', 'name', 'slug', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = SiteGroup
fields = (
@@ -150,14 +163,14 @@ class SiteForm(TenancyForm, NetBoxModelForm):
'time_zone': StaticSelect(),
}
help_texts = {
- 'name': "Full name of the site",
- 'facility': "Data center provider and facility (e.g. Equinix NY7)",
- 'time_zone': "Local time zone",
- 'description': "Short description (will appear in sites list)",
- 'physical_address': "Physical location of the building (e.g. for GPS)",
- 'shipping_address': "If different from the physical address",
- 'latitude': "Latitude in decimal format (xx.yyyyyy)",
- 'longitude': "Longitude in decimal format (xx.yyyyyy)"
+ 'name': _("Full name of the site"),
+ 'facility': _("Data center provider and facility (e.g. Equinix NY7)"),
+ 'time_zone': _("Local time zone"),
+ 'description': _("Short description (will appear in sites list)"),
+ 'physical_address': _("Physical location of the building (e.g. for GPS)"),
+ 'shipping_address': _("If different from the physical address"),
+ 'latitude': _("Latitude in decimal format (xx.yyyyyy)"),
+ 'longitude': _("Longitude in decimal format (xx.yyyyyy)")
}
@@ -213,6 +226,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class RackRoleForm(NetBoxModelForm):
slug = SlugField()
+ fieldsets = (
+ ('Rack Role', (
+ 'name', 'slug', 'color', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = RackRole
fields = [
@@ -260,19 +279,20 @@ 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', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
]
help_texts = {
- 'site': "The site at which the rack exists",
- 'name': "Organizational rack name",
- 'facility_id': "The unique rack ID assigned by the facility",
- 'u_height': "Height in rack units",
+ 'site': _("The site at which the rack exists"),
+ 'name': _("Organizational rack name"),
+ 'facility_id': _("The unique rack ID assigned by the facility"),
+ 'u_height': _("Height in rack units"),
}
widgets = {
'status': StaticSelect(),
'type': StaticSelect(),
'width': StaticSelect(),
'outer_unit': StaticSelect(),
+ 'weight_unit': StaticSelect(),
}
@@ -315,7 +335,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
)
units = NumericArrayField(
base_field=forms.IntegerField(),
- help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
+ help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
@@ -323,6 +343,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
),
widget=StaticSelect()
)
+ comments = CommentField()
fieldsets = (
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
@@ -333,13 +354,19 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
model = RackReservation
fields = [
'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
- 'description', 'tags',
+ 'description', 'comments', 'tags',
]
class ManufacturerForm(NetBoxModelForm):
slug = SlugField()
+ fieldsets = (
+ ('Manufacturer', (
+ 'name', 'slug', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = Manufacturer
fields = [
@@ -357,11 +384,9 @@ class DeviceTypeForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
- ('Device Type', (
- 'manufacturer', 'model', 'slug', 'part_number', 'tags',
- )),
+ ('Device Type', ('manufacturer', 'model', 'slug', 'description', 'tags')),
('Chassis', (
- 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+ 'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
)),
('Images', ('front_image', 'rear_image')),
)
@@ -370,16 +395,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', 'description', '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(),
}
@@ -390,21 +417,30 @@ class ModuleTypeForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
- ('Module Type', (
- 'manufacturer', 'model', 'part_number', 'tags',
- )),
+ ('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')),
+ ('Weight', ('weight', 'weight_unit'))
)
class Meta:
model = ModuleType
fields = [
- 'manufacturer', 'model', 'part_number', 'comments', 'tags',
+ 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
]
+ widgets = {
+ 'weight_unit': StaticSelect(),
+ }
+
class DeviceRoleForm(NetBoxModelForm):
slug = SlugField()
+ fieldsets = (
+ ('Device Role', (
+ 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = DeviceRole
fields = [
@@ -421,6 +457,13 @@ class PlatformForm(NetBoxModelForm):
max_length=64
)
+ fieldsets = (
+ ('Platform', (
+ 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+
+ )),
+ )
+
class Meta:
model = Platform
fields = [
@@ -473,7 +516,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
)
position = forms.DecimalField(
required=False,
- help_text="The lowest-numbered unit occupied by the device",
+ help_text=_("The lowest-numbered unit occupied by the device"),
widget=APISelect(
api_url='/api/dcim/racks/{{rack}}/elevation/',
attrs={
@@ -531,13 +574,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
)
vc_position = forms.IntegerField(
required=False,
- label='Position',
- help_text="The position in the virtual chassis this device is identified by"
+ label=_('Position'),
+ help_text=_("The position in the virtual chassis this device is identified by")
)
vc_priority = forms.IntegerField(
required=False,
- label='Priority',
- help_text="The priority of the device in the virtual chassis"
+ label=_('Priority'),
+ help_text=_("The priority of the device in the virtual chassis")
)
class Meta:
@@ -546,13 +589,13 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
- 'comments', 'tags', 'local_context_data'
+ 'description', 'comments', 'tags', 'local_context_data'
]
help_texts = {
- 'device_role': "The function this device serves",
- 'serial': "Chassis serial number",
- 'local_context_data': "Local config context data overwrites all source contexts in the final rendered "
- "config context",
+ 'device_role': _("The function this device serves"),
+ 'serial': _("Chassis serial number"),
+ 'local_context_data': _("Local config context data overwrites all source contexts in the final rendered "
+ "config context"),
}
widgets = {
'face': StaticSelect(),
@@ -619,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
-class ModuleForm(NetBoxModelForm):
+class ModuleForm(ModuleCommonForm, NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
initial_params={
@@ -649,18 +692,18 @@ class ModuleForm(NetBoxModelForm):
replicate_components = forms.BooleanField(
required=False,
initial=True,
- help_text="Automatically populate components associated with this module type"
+ help_text=_("Automatically populate components associated with this module type")
)
adopt_components = forms.BooleanField(
required=False,
initial=False,
- help_text="Adopt already existing components"
+ help_text=_("Adopt already existing components")
)
fieldsets = (
('Module', (
- 'device', 'module_bay', 'manufacturer', 'module_type', 'tags',
+ 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
)),
('Hardware', (
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
@@ -670,88 +713,29 @@ class ModuleForm(NetBoxModelForm):
class Meta:
model = Module
fields = [
- 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
- 'replicate_components', 'adopt_components', 'comments',
+ 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
+ 'replicate_components', 'adopt_components', 'description', 'comments',
]
def __init__(self, *args, **kwargs):
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
self.fields['adopt_components'].disabled = True
- def save(self, *args, **kwargs):
-
- # If replicate_components is False, disable automatic component replication on the instance
- if self.instance.pk or not self.cleaned_data['replicate_components']:
- self.instance._disable_replication = True
-
- if self.cleaned_data['adopt_components']:
- self.instance._adopt_components = True
-
- return super().save(*args, **kwargs)
-
- def clean(self):
- super().clean()
-
- replicate_components = self.cleaned_data.get("replicate_components")
- adopt_components = self.cleaned_data.get("adopt_components")
- device = self.cleaned_data['device']
- module_type = self.cleaned_data['module_type']
- module_bay = self.cleaned_data['module_bay']
-
- # Bail out if we are not installing a new module or if we are not replicating components
- if self.instance.pk or not replicate_components:
- return
-
- for templates, component_attribute in [
- ("consoleporttemplates", "consoleports"),
- ("consoleserverporttemplates", "consoleserverports"),
- ("interfacetemplates", "interfaces"),
- ("powerporttemplates", "powerports"),
- ("poweroutlettemplates", "poweroutlets"),
- ("rearporttemplates", "rearports"),
- ("frontporttemplates", "frontports")
- ]:
- # Prefetch installed components
- installed_components = {
- component.name: component for component in getattr(device, component_attribute).all()
- }
-
- # Get the templates for the module type.
- for template in getattr(module_type, templates).all():
- # Installing modules with placeholders require that the bay has a position value
- if MODULE_TOKEN in template.name and not module_bay.position:
- raise forms.ValidationError(
- "Cannot install module with placeholder values in a module bay with no position defined"
- )
-
- resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
- existing_item = installed_components.get(resolved_name)
-
- # It is not possible to adopt components already belonging to a module
- if adopt_components and existing_item and existing_item.module:
- raise forms.ValidationError(
- f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
- f"to a module"
- )
-
- # If we are not adopting components we error if the component exists
- if not adopt_components and resolved_name in installed_components:
- raise forms.ValidationError(
- f"{template.component_model.__name__} - {resolved_name} already exists"
- )
-
class CableForm(TenancyForm, NetBoxModelForm):
+ comments = CommentField()
class Meta:
model = Cable
fields = [
- 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
+ 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
+ 'comments', 'tags',
]
widgets = {
'status': StaticSelect,
@@ -794,15 +778,16 @@ class PowerPanelForm(NetBoxModelForm):
'site_id': '$site'
}
)
+ comments = CommentField()
fieldsets = (
- ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')),
+ ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')),
)
class Meta:
model = PowerPanel
fields = [
- 'region', 'site_group', 'site', 'location', 'name', 'tags',
+ 'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags',
]
@@ -838,10 +823,21 @@ class PowerFeedForm(NetBoxModelForm):
'site_id': '$site'
}
)
+ location = DynamicModelChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ },
+ initial_params={
+ 'racks': '$rack'
+ }
+ )
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
+ 'location_id': '$location',
'site_id': '$site'
}
)
@@ -849,15 +845,16 @@ class PowerFeedForm(NetBoxModelForm):
fieldsets = (
('Power Panel', ('region', 'site', 'power_panel')),
- ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
+ ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
)
class Meta:
model = PowerFeed
fields = [
- 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
- 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
+ 'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type',
+ 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments',
+ 'tags',
]
widgets = {
'status': StaticSelect(),
@@ -876,11 +873,12 @@ class VirtualChassisForm(NetBoxModelForm):
queryset=Device.objects.all(),
required=False,
)
+ comments = CommentField()
class Meta:
model = VirtualChassis
fields = [
- 'name', 'domain', 'master', 'tags',
+ 'name', 'domain', 'master', 'description', 'comments', 'tags',
]
widgets = {
'master': SelectWithPK(),
@@ -986,47 +984,85 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
# Device component templates
#
+class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
+ device_type = DynamicModelChoiceField(
+ queryset=DeviceType.objects.all()
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Disable reassignment of DeviceType when editing an existing instance
+ if self.instance.pk:
+ self.fields['device_type'].disabled = True
+
+
+class ModularComponentTemplateForm(ComponentTemplateForm):
+ device_type = DynamicModelChoiceField(
+ queryset=DeviceType.objects.all().all(),
+ required=False
+ )
+ module_type = DynamicModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ required=False
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Disable reassignment of ModuleType when editing an existing instance
+ if self.instance.pk:
+ self.fields['module_type'].disabled = True
+
+
+class ConsolePortTemplateForm(ModularComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+ )
-class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect,
}
-class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
+ )
+
class Meta:
model = ConsoleServerPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect,
}
-class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class PowerPortTemplateForm(ModularComponentTemplateForm):
+ fieldsets = (
+ (None, (
+ 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+ )),
+ )
+
class Meta:
model = PowerPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
-class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+class PowerOutletTemplateForm(ModularComponentTemplateForm):
power_port = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
@@ -1035,35 +1071,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
)
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')),
+ )
+
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
-class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
+class InterfaceTemplateForm(ModularComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')),
+ ('PoE', ('poe_mode', 'poe_type'))
+ )
+
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}
-class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class FrontPortTemplateForm(ModularComponentTemplateForm):
rear_port = DynamicModelChoiceField(
queryset=RearPortTemplate.objects.all(),
required=False,
@@ -1073,6 +1114,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
)
+ fieldsets = (
+ (None, (
+ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
+ 'description',
+ )),
+ )
+
class Meta:
model = FrontPortTemplate
fields = [
@@ -1080,48 +1128,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
-class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
+class RearPortTemplateForm(ModularComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')),
+ )
+
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
]
widgets = {
- 'device_type': forms.HiddenInput(),
- 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
-class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm):
+class ModuleBayTemplateForm(ComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'name', 'label', 'position', 'description')),
+ )
+
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'name', 'label', 'position', 'description',
]
- widgets = {
- 'device_type': forms.HiddenInput(),
- }
-class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
+class DeviceBayTemplateForm(ComponentTemplateForm):
+ fieldsets = (
+ (None, ('device_type', 'name', 'label', 'description')),
+ )
+
class Meta:
model = DeviceBayTemplate
fields = [
'device_type', 'name', 'label', 'description',
]
- widgets = {
- 'device_type': forms.HiddenInput(),
- }
-class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
+class InventoryItemTemplateForm(ComponentTemplateForm):
parent = DynamicModelChoiceField(
queryset=InventoryItemTemplate.objects.all(),
required=False,
@@ -1148,22 +1198,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
widget=forms.HiddenInput
)
+ fieldsets = (
+ (None, (
+ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
+ 'component_type', 'component_id',
+ )),
+ )
+
class Meta:
model = InventoryItemTemplate
fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
]
- widgets = {
- 'device_type': forms.HiddenInput(),
- }
#
# Device components
#
-class ConsolePortForm(NetBoxModelForm):
+class DeviceComponentForm(NetBoxModelForm):
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all()
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Disable reassignment of Device when editing an existing instance
+ if self.instance.pk:
+ self.fields['device'].disabled = True
+
+
+class ModularDeviceComponentForm(DeviceComponentForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
@@ -1172,25 +1239,31 @@ class ConsolePortForm(NetBoxModelForm):
}
)
+
+class ConsolePortForm(ModularDeviceComponentForm):
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = ConsolePort
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
}
-class ConsoleServerPortForm(NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
+class ConsoleServerPortForm(ModularDeviceComponentForm):
+
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
+ )),
)
class Meta:
@@ -1199,42 +1272,32 @@ class ConsoleServerPortForm(NetBoxModelForm):
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
}
-class PowerPortForm(NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
+class PowerPortForm(ModularDeviceComponentForm):
+
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
+ 'description', 'tags',
+ )),
)
class Meta:
model = PowerPort
fields = [
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
- 'description',
- 'tags',
+ 'description', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
}
-class PowerOutletForm(NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
- )
+class PowerOutletForm(ModularDeviceComponentForm):
power_port = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
@@ -1243,6 +1306,13 @@ class PowerOutletForm(NetBoxModelForm):
}
)
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
+ 'tags',
+ )),
+ )
+
class Meta:
model = PowerOutlet
fields = [
@@ -1250,16 +1320,16 @@ class PowerOutletForm(NetBoxModelForm):
'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
-class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
+class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
+ vdcs = DynamicModelMultipleChoiceField(
+ queryset=VirtualDeviceContext.objects.all(),
required=False,
+ label='Virtual Device Contexts',
query_params={
'device_id': '$device',
}
@@ -1267,7 +1337,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='Parent interface',
+ label=_('Parent interface'),
query_params={
'device_id': '$device',
}
@@ -1275,7 +1345,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='Bridged interface',
+ label=_('Bridged interface'),
query_params={
'device_id': '$device',
}
@@ -1283,7 +1353,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='LAG interface',
+ label=_('LAG interface'),
query_params={
'device_id': '$device',
'type': 'lag',
@@ -1292,12 +1362,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
wireless_lan_group = DynamicModelChoiceField(
queryset=WirelessLANGroup.objects.all(),
required=False,
- label='Wireless LAN group'
+ label=_('Wireless LAN group')
)
wireless_lans = DynamicModelMultipleChoiceField(
queryset=WirelessLAN.objects.all(),
required=False,
- label='Wireless LANs',
+ label=_('Wireless LANs'),
query_params={
'group_id': '$wireless_lan_group',
}
@@ -1305,12 +1375,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
- label='VLAN group'
+ label=_('VLAN group')
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
- label='Untagged VLAN',
+ label=_('Untagged VLAN'),
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
@@ -1319,7 +1389,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False,
- label='Tagged VLANs',
+ label=_('Tagged VLANs'),
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
@@ -1328,19 +1398,18 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
- label='VRF'
+ label=_('VRF')
)
-
wwn = forms.CharField(
empty_value=None,
required=False,
- label='WWN'
+ label=_('WWN')
)
fieldsets = (
- ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
+ ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
- ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
+ ('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
@@ -1352,13 +1421,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
class Meta:
model = Interface
fields = [
- 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
+ 'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
@@ -1373,29 +1441,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
- 'rf_channel_frequency': "Populated by selected channel (if set)",
- 'rf_channel_width': "Populated by selected channel (if set)",
+ 'rf_channel_frequency': _("Populated by selected channel (if set)"),
+ 'rf_channel_width': _("Populated by selected channel (if set)"),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- # Restrict LAG/bridge interface assignment by device/VC
- device_id = self.data['device'] if self.is_bound else self.initial.get('device')
- device = Device.objects.filter(pk=device_id).first()
- if device and device.virtual_chassis and device.virtual_chassis.master:
- self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
- self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-
-
-class FrontPortForm(NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
- )
+class FrontPortForm(ModularDeviceComponentForm):
rear_port = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
query_params={
@@ -1403,6 +1454,13 @@ class FrontPortForm(NetBoxModelForm):
}
)
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
+ 'description', 'tags',
+ )),
+ )
+
class Meta:
model = FrontPort
fields = [
@@ -1410,18 +1468,15 @@ class FrontPortForm(NetBoxModelForm):
'description', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
}
-class RearPortForm(NetBoxModelForm):
- module = DynamicModelChoiceField(
- queryset=Module.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
+class RearPortForm(ModularDeviceComponentForm):
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
+ )),
)
class Meta:
@@ -1430,40 +1485,39 @@ class RearPortForm(NetBoxModelForm):
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
]
widgets = {
- 'device': forms.HiddenInput(),
'type': StaticSelect(),
}
-class ModuleBayForm(NetBoxModelForm):
+class ModuleBayForm(DeviceComponentForm):
+ fieldsets = (
+ (None, ('device', 'name', 'label', 'position', 'description', 'tags',)),
+ )
class Meta:
model = ModuleBay
fields = [
'device', 'name', 'label', 'position', 'description', 'tags',
]
- widgets = {
- 'device': forms.HiddenInput(),
- }
-class DeviceBayForm(NetBoxModelForm):
+class DeviceBayForm(DeviceComponentForm):
+ fieldsets = (
+ (None, ('device', 'name', 'label', 'description', 'tags',)),
+ )
class Meta:
model = DeviceBay
fields = [
'device', 'name', 'label', 'description', 'tags',
]
- widgets = {
- 'device': forms.HiddenInput(),
- }
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField(
queryset=Device.objects.all(),
- label='Child Device',
- help_text="Child devices must first be created and assigned to the site/rack of the parent device.",
+ label=_('Child Device'),
+ help_text=_("Child devices must first be created and assigned to the site/rack of the parent device."),
widget=StaticSelect(),
)
@@ -1479,10 +1533,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
-class InventoryItemForm(NetBoxModelForm):
- device = DynamicModelChoiceField(
- queryset=Device.objects.all()
- )
+class InventoryItemForm(DeviceComponentForm):
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
@@ -1498,15 +1549,63 @@ class InventoryItemForm(NetBoxModelForm):
queryset=Manufacturer.objects.all(),
required=False
)
- component_type = ContentTypeChoiceField(
- queryset=ContentType.objects.all(),
- limit_choices_to=MODULAR_COMPONENT_MODELS,
+
+ # Assigned component selectors
+ consoleport = DynamicModelChoiceField(
+ queryset=ConsolePort.objects.all(),
required=False,
- widget=forms.HiddenInput
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Console port')
)
- component_id = forms.IntegerField(
+ consoleserverport = DynamicModelChoiceField(
+ queryset=ConsoleServerPort.objects.all(),
required=False,
- widget=forms.HiddenInput
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Console server port')
+ )
+ frontport = DynamicModelChoiceField(
+ queryset=FrontPort.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Front port')
+ )
+ interface = DynamicModelChoiceField(
+ queryset=Interface.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Interface')
+ )
+ poweroutlet = DynamicModelChoiceField(
+ queryset=PowerOutlet.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Power outlet')
+ )
+ powerport = DynamicModelChoiceField(
+ queryset=PowerPort.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Power port')
+ )
+ rearport = DynamicModelChoiceField(
+ queryset=RearPort.objects.all(),
+ required=False,
+ query_params={
+ 'device_id': '$device'
+ },
+ label=_('Rear port')
)
fieldsets = (
@@ -1518,19 +1617,158 @@ class InventoryItemForm(NetBoxModelForm):
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
- 'description', 'component_type', 'component_id', 'tags',
+ 'description', 'tags',
]
+ def __init__(self, *args, **kwargs):
+ instance = kwargs.get('instance')
+ initial = kwargs.get('initial', {}).copy()
+ component_type = initial.get('component_type')
+ component_id = initial.get('component_id')
+
+ # Used for picking the default active tab for component selection
+ self.no_component = True
+
+ if instance:
+ # When editing set the initial value for component selectin
+ for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
+ if type(instance.component) is component_model.model_class():
+ initial[component_model.model] = instance.component
+ self.no_component = False
+ break
+ elif component_type and component_id:
+ # When adding the InventoryItem from a component page
+ if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
+ if component := content_type.model_class().objects.filter(pk=component_id).first():
+ initial[content_type.model] = component
+ self.no_component = False
+
+ kwargs['initial'] = initial
+
+ super().__init__(*args, **kwargs)
+
+ # Specifically allow editing the device of IntentoryItems
+ if self.instance.pk:
+ self.fields['device'].disabled = False
+
+ def clean(self):
+ super().clean()
+
+ # Handle object assignment
+ selected_objects = [
+ field for field in (
+ 'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
+ ) if self.cleaned_data[field]
+ ]
+ if len(selected_objects) > 1:
+ raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
+ elif selected_objects:
+ self.instance.component = self.cleaned_data[selected_objects[0]]
+ else:
+ self.instance.component = None
+
-#
# Device component roles
#
class InventoryItemRoleForm(NetBoxModelForm):
slug = SlugField()
+ fieldsets = (
+ ('Inventory Item Role', (
+ 'name', 'slug', 'color', 'description', 'tags',
+ )),
+ )
+
class Meta:
model = InventoryItemRole
fields = [
'name', 'slug', 'color', 'description', 'tags',
]
+
+
+class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
+ region = DynamicModelChoiceField(
+ queryset=Region.objects.all(),
+ required=False,
+ initial_params={
+ 'sites': '$site'
+ }
+ )
+ site_group = DynamicModelChoiceField(
+ queryset=SiteGroup.objects.all(),
+ required=False,
+ initial_params={
+ 'sites': '$site'
+ }
+ )
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False,
+ query_params={
+ 'region_id': '$region',
+ 'group_id': '$site_group',
+ }
+ )
+ location = DynamicModelChoiceField(
+ queryset=Location.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ },
+ initial_params={
+ 'racks': '$rack'
+ }
+ )
+ rack = DynamicModelChoiceField(
+ queryset=Rack.objects.all(),
+ required=False,
+ query_params={
+ 'site_id': '$site',
+ 'location_id': '$location',
+ }
+ )
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ query_params={
+ 'site_id': '$site',
+ 'location_id': '$location',
+ 'rack_id': '$rack',
+ }
+ )
+ primary_ip4 = DynamicModelChoiceField(
+ queryset=IPAddress.objects.all(),
+ label='Primary IPv4',
+ required=False,
+ query_params={
+ 'device_id': '$device',
+ 'family': '4',
+ }
+ )
+ primary_ip6 = DynamicModelChoiceField(
+ queryset=IPAddress.objects.all(),
+ label='Primary IPv6',
+ required=False,
+ query_params={
+ 'device_id': '$device',
+ 'family': '6',
+ }
+ )
+
+ fieldsets = (
+ ('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
+ ('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
+ ('Tenancy', ('tenant_group', 'tenant'))
+ )
+
+ class Meta:
+ model = VirtualDeviceContext
+ fields = [
+ 'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier',
+ 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags'
+ ]
+ widgets = {
+ 'status': StaticSelect(),
+ 'primary_ip4': StaticSelect(),
+ 'primary_ip6': StaticSelect(),
+ }
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index d2c941b34..6de193043 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -1,47 +1,58 @@
from django import forms
+from django.utils.translation import gettext as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
-from utilities.forms import (
- BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
-)
+from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from . import model_forms
__all__ = (
- 'ComponentTemplateCreateForm',
- 'DeviceComponentCreateForm',
+ 'ComponentCreateForm',
+ 'ConsolePortCreateForm',
+ 'ConsolePortTemplateCreateForm',
+ 'ConsoleServerPortCreateForm',
+ 'ConsoleServerPortTemplateCreateForm',
+ 'DeviceBayCreateForm',
+ 'DeviceBayTemplateCreateForm',
'FrontPortCreateForm',
'FrontPortTemplateCreateForm',
+ 'InterfaceCreateForm',
+ 'InterfaceTemplateCreateForm',
'InventoryItemCreateForm',
- 'ModularComponentTemplateCreateForm',
+ 'InventoryItemTemplateCreateForm',
'ModuleBayCreateForm',
'ModuleBayTemplateCreateForm',
+ 'PowerOutletCreateForm',
+ 'PowerOutletTemplateCreateForm',
+ 'PowerPortCreateForm',
+ 'PowerPortTemplateCreateForm',
+ 'RearPortCreateForm',
+ 'RearPortTemplateCreateForm',
'VirtualChassisCreateForm',
)
-class ComponentCreateForm(BootstrapMixin, forms.Form):
+class ComponentCreateForm(forms.Form):
"""
- Subclass this form when facilitating the creation of one or more device component or component templates based on
+ Subclass this form when facilitating the creation of one or more component or component template objects based on
a name pattern.
"""
- name_pattern = ExpandableNameField(
- label='Name'
- )
- label_pattern = ExpandableNameField(
- label='Label',
+ name = ExpandableNameField()
+ label = ExpandableNameField(
required=False,
- help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)')
)
+ # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by
+ # ComponentCreateView when creating objects.
+ replication_fields = ('name', 'label')
+
def clean(self):
super().clean()
- # Validate that all patterned fields generate an equal number of values
- patterned_fields = [
- field_name for field_name in self.fields if field_name.endswith('_pattern')
- ]
- pattern_count = len(self.cleaned_data['name_pattern'])
- for field_name in patterned_fields:
+ # Validate that all replication fields generate an equal number of values
+ pattern_count = len(self.cleaned_data[self.replication_fields[0]])
+ for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
@@ -50,56 +61,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
}, code='label_pattern_mismatch')
-class ComponentTemplateCreateForm(ComponentCreateForm):
- """
- Creation form for component templates that can be assigned only to a DeviceType.
- """
- device_type = DynamicModelChoiceField(
- queryset=DeviceType.objects.all(),
- )
- field_order = ('device_type', 'name_pattern', 'label_pattern')
+#
+# Device component templates
+#
+
+class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm):
+
+ class Meta(model_forms.ConsolePortTemplateForm.Meta):
+ exclude = ('name', 'label')
-class ModularComponentTemplateCreateForm(ComponentCreateForm):
- """
- Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
- """
- name_pattern = ExpandableNameField(
- label='Name',
- help_text="""
- Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
- are not supported. Example: [ge,xe]-0/0/[0-9]
. {module} is accepted as a substitution for
- the module bay position.
- """
- )
- device_type = DynamicModelChoiceField(
- queryset=DeviceType.objects.all(),
- required=False
- )
- module_type = DynamicModelChoiceField(
- queryset=ModuleType.objects.all(),
- required=False
- )
- field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern')
+class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm):
+
+ class Meta(model_forms.ConsoleServerPortTemplateForm.Meta):
+ exclude = ('name', 'label')
-class DeviceComponentCreateForm(ComponentCreateForm):
- device = DynamicModelChoiceField(
- queryset=Device.objects.all()
- )
- field_order = ('device', 'name_pattern', 'label_pattern')
+class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm):
+
+ class Meta(model_forms.PowerPortTemplateForm.Meta):
+ exclude = ('name', 'label')
-class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
- rear_port_set = forms.MultipleChoiceField(
+class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm):
+
+ class Meta(model_forms.PowerOutletTemplateForm.Meta):
+ exclude = ('name', 'label')
+
+
+class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
+
+ class Meta(model_forms.InterfaceTemplateForm.Meta):
+ exclude = ('name', 'label')
+
+
+class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
+ rear_port = forms.MultipleChoiceField(
choices=[],
- label='Rear ports',
- help_text='Select one rear port assignment for each front port being created.',
+ label=_('Rear ports'),
+ help_text=_('Select one rear port assignment for each front port being created.'),
)
- field_order = (
- 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set',
+
+ # Override fieldsets from FrontPortTemplateForm to omit rear_port_position
+ fieldsets = (
+ (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')),
)
+ class Meta(model_forms.FrontPortTemplateForm.Meta):
+ exclude = ('name', 'label', 'rear_port', 'rear_port_position')
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -130,12 +140,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
- self.fields['rear_port_set'].choices = choices
+ self.fields['rear_port'].choices = choices
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
- rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+ rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return {
'rear_port': int(rear_port),
@@ -143,16 +153,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
}
-class FrontPortCreateForm(DeviceComponentCreateForm):
- rear_port_set = forms.MultipleChoiceField(
+class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm):
+
+ class Meta(model_forms.RearPortTemplateForm.Meta):
+ exclude = ('name', 'label')
+
+
+class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm):
+
+ class Meta(model_forms.DeviceBayTemplateForm.Meta):
+ exclude = ('name', 'label')
+
+
+class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm):
+ position = ExpandableNameField(
+ label=_('Position'),
+ required=False,
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)')
+ )
+ replication_fields = ('name', 'label', 'position')
+
+ class Meta(model_forms.ModuleBayTemplateForm.Meta):
+ exclude = ('name', 'label', 'position')
+
+
+class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm):
+
+ class Meta(model_forms.InventoryItemTemplateForm.Meta):
+ exclude = ('name', 'label')
+
+
+#
+# Device components
+#
+
+class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm):
+
+ class Meta(model_forms.ConsolePortForm.Meta):
+ exclude = ('name', 'label')
+
+
+class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm):
+
+ class Meta(model_forms.ConsoleServerPortForm.Meta):
+ exclude = ('name', 'label')
+
+
+class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm):
+
+ class Meta(model_forms.PowerPortForm.Meta):
+ exclude = ('name', 'label')
+
+
+class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
+
+ class Meta(model_forms.PowerOutletForm.Meta):
+ exclude = ('name', 'label')
+
+
+class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
+
+ class Meta(model_forms.InterfaceForm.Meta):
+ exclude = ('name', 'label')
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ if 'module' in self.fields:
+ self.fields['name'].help_text += ' The string {module}
will be replaced with the position ' \
+ 'of the assigned module, if any'
+
+
+class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
+ rear_port = forms.MultipleChoiceField(
choices=[],
- label='Rear ports',
- help_text='Select one rear port assignment for each front port being created.',
+ label=_('Rear ports'),
+ help_text=_('Select one rear port assignment for each front port being created.'),
)
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'rear_port_set',
+
+ # Override fieldsets from FrontPortForm to omit rear_port_position
+ fieldsets = (
+ (None, (
+ 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
+ )),
)
+ class Meta(model_forms.FrontPortForm.Meta):
+ exclude = ('name', 'label', 'rear_port', 'rear_port_position')
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -176,12 +264,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
- self.fields['rear_port_set'].choices = choices
+ self.fields['rear_port'].choices = choices
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
- rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+ rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return {
'rear_port': int(rear_port),
@@ -189,28 +277,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
}
-class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
- position_pattern = ExpandableNameField(
- label='Position',
+class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm):
+
+ class Meta(model_forms.RearPortForm.Meta):
+ exclude = ('name', 'label')
+
+
+class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm):
+
+ class Meta(model_forms.DeviceBayForm.Meta):
+ exclude = ('name', 'label')
+
+
+class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm):
+ position = ExpandableNameField(
+ label=_('Position'),
required=False,
- help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
+ help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)')
)
- field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
+ replication_fields = ('name', 'label', 'position')
+
+ class Meta(model_forms.ModuleBayForm.Meta):
+ exclude = ('name', 'label', 'position')
-class ModuleBayCreateForm(DeviceComponentCreateForm):
- position_pattern = ExpandableNameField(
- label='Position',
- required=False,
- help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
- )
- field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
+class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm):
+
+ class Meta(model_forms.InventoryItemForm.Meta):
+ exclude = ('name', 'label')
-class InventoryItemCreateForm(ComponentCreateForm):
- # Device is assigned by the model form
- field_order = ('name_pattern', 'label_pattern')
-
+#
+# Virtual chassis
+#
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(
@@ -254,7 +353,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
initial_position = forms.IntegerField(
initial=1,
required=False,
- help_text='Position of the first member device. Increases by one for each additional member.'
+ help_text=_('Position of the first member device. Increases by one for each additional member.')
)
class Meta:
diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py
index 023aba8f1..dfa1a4c6a 100644
--- a/netbox/dcim/forms/object_import.py
+++ b/netbox/dcim/forms/object_import.py
@@ -1,4 +1,5 @@
from django import forms
+from django.utils.translation import gettext as _
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
@@ -8,43 +9,16 @@ __all__ = (
'ConsolePortTemplateImportForm',
'ConsoleServerPortTemplateImportForm',
'DeviceBayTemplateImportForm',
- 'DeviceTypeImportForm',
'FrontPortTemplateImportForm',
'InterfaceTemplateImportForm',
'InventoryItemTemplateImportForm',
'ModuleBayTemplateImportForm',
- 'ModuleTypeImportForm',
'PowerOutletTemplateImportForm',
'PowerPortTemplateImportForm',
'RearPortTemplateImportForm',
)
-class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
- manufacturer = forms.ModelChoiceField(
- queryset=Manufacturer.objects.all(),
- to_field_name='name'
- )
-
- class Meta:
- model = DeviceType
- fields = [
- 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
- 'comments',
- ]
-
-
-class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm):
- manufacturer = forms.ModelChoiceField(
- queryset=Manufacturer.objects.all(),
- to_field_name='name'
- )
-
- class Meta:
- model = ModuleType
- fields = ['manufacturer', 'model', 'part_number', 'comments']
-
-
#
# Component template import forms
#
@@ -115,12 +89,12 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
poe_mode = forms.ChoiceField(
choices=InterfacePoEModeChoices,
required=False,
- label='PoE mode'
+ label=_('PoE mode')
)
poe_type = forms.ChoiceField(
choices=InterfacePoETypeChoices,
required=False,
- label='PoE type'
+ label=_('PoE type')
)
class Meta:
diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py
new file mode 100644
index 000000000..f3b8b696b
--- /dev/null
+++ b/netbox/dcim/graphql/gfk_mixins.py
@@ -0,0 +1,169 @@
+import graphene
+from circuits.graphql.types import CircuitTerminationType
+from circuits.models import CircuitTermination
+from dcim.graphql.types import (
+ ConsolePortTemplateType,
+ ConsolePortType,
+ ConsoleServerPortTemplateType,
+ ConsoleServerPortType,
+ FrontPortTemplateType,
+ FrontPortType,
+ InterfaceTemplateType,
+ InterfaceType,
+ PowerFeedType,
+ PowerOutletTemplateType,
+ PowerOutletType,
+ PowerPortTemplateType,
+ PowerPortType,
+ RearPortTemplateType,
+ RearPortType,
+)
+from dcim.models import (
+ ConsolePort,
+ ConsolePortTemplate,
+ ConsoleServerPort,
+ ConsoleServerPortTemplate,
+ FrontPort,
+ FrontPortTemplate,
+ Interface,
+ InterfaceTemplate,
+ PowerFeed,
+ PowerOutlet,
+ PowerOutletTemplate,
+ PowerPort,
+ PowerPortTemplate,
+ RearPort,
+ RearPortTemplate,
+)
+
+
+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
+
+
+class CableTerminationTerminationType(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
+
+
+class InventoryItemTemplateComponentType(graphene.Union):
+ class Meta:
+ types = (
+ ConsolePortTemplateType,
+ ConsoleServerPortTemplateType,
+ FrontPortTemplateType,
+ InterfaceTemplateType,
+ PowerOutletTemplateType,
+ PowerPortTemplateType,
+ RearPortTemplateType,
+ )
+
+ @classmethod
+ def resolve_type(cls, instance, info):
+ if type(instance) == ConsolePortTemplate:
+ return ConsolePortTemplateType
+ if type(instance) == ConsoleServerPortTemplate:
+ return ConsoleServerPortTemplateType
+ if type(instance) == FrontPortTemplate:
+ return FrontPortTemplateType
+ if type(instance) == InterfaceTemplate:
+ return InterfaceTemplateType
+ if type(instance) == PowerOutletTemplate:
+ return PowerOutletTemplateType
+ if type(instance) == PowerPortTemplate:
+ return PowerPortTemplateType
+ if type(instance) == RearPortTemplate:
+ return RearPortTemplateType
+
+
+class InventoryItemComponentType(graphene.Union):
+ class Meta:
+ types = (
+ ConsolePortType,
+ ConsoleServerPortType,
+ FrontPortType,
+ InterfaceType,
+ PowerOutletType,
+ PowerPortType,
+ RearPortType,
+ )
+
+ @classmethod
+ def resolve_type(cls, instance, info):
+ if type(instance) == ConsolePort:
+ return ConsolePortType
+ if type(instance) == ConsoleServerPort:
+ return ConsoleServerPortType
+ if type(instance) == FrontPort:
+ return FrontPortType
+ if type(instance) == Interface:
+ return InterfaceType
+ 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/schema.py b/netbox/dcim/graphql/schema.py
index 1d5b6a580..eba311420 100644
--- a/netbox/dcim/graphql/schema.py
+++ b/netbox/dcim/graphql/schema.py
@@ -2,6 +2,7 @@ import graphene
from netbox.graphql.fields import ObjectField, ObjectListField
from .types import *
+from .types import VirtualDeviceContextType
class DCIMQuery(graphene.ObjectType):
@@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType):
virtual_chassis = ObjectField(VirtualChassisType)
virtual_chassis_list = ObjectListField(VirtualChassisType)
+
+ virtual_device_context = ObjectField(VirtualDeviceContextType)
+ virtual_device_context_list = ObjectListField(VirtualDeviceContextType)
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 52a98278a..41f0092f9 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -2,7 +2,7 @@ import graphene
from dcim import filtersets, models
from extras.graphql.mixins import (
- ChangelogMixin, ConfigContextMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
+ ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin,
)
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
@@ -87,6 +87,8 @@ class ComponentTemplateObjectType(
#
class CableType(NetBoxObjectType):
+ a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
+ b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
class Meta:
model = models.Cable
@@ -99,12 +101,19 @@ class CableType(NetBoxObjectType):
def resolve_length_unit(self, info):
return self.length_unit or None
+ def resolve_a_terminations(self, info):
+ return self.a_terminations
+
+ def resolve_b_terminations(self, info):
+ return self.b_terminations
+
class CableTerminationType(NetBoxObjectType):
+ termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType')
class Meta:
model = models.CableTermination
- fields = '__all__'
+ exclude = ('termination_type', 'termination_id')
filterset_class = filtersets.CableTerminationFilterSet
@@ -152,7 +161,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType):
return self.type or None
-class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
class Meta:
model = models.Device
@@ -183,10 +192,11 @@ class DeviceBayTemplateType(ComponentTemplateObjectType):
class InventoryItemTemplateType(ComponentTemplateObjectType):
+ component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType')
class Meta:
model = models.InventoryItemTemplate
- fields = '__all__'
+ exclude = ('component_type', 'component_id')
filterset_class = filtersets.InventoryItemTemplateFilterSet
@@ -211,6 +221,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):
@@ -266,10 +279,11 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
class InventoryItemType(ComponentObjectType):
+ component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')
class Meta:
model = models.InventoryItem
- fields = '__all__'
+ exclude = ('component_type', 'component_id')
filterset_class = filtersets.InventoryItemFilterSet
@@ -281,7 +295,7 @@ class InventoryItemRoleType(OrganizationalObjectType):
filterset_class = filtersets.InventoryItemRoleFilterSet
-class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
+class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType):
class Meta:
model = models.Location
@@ -289,7 +303,7 @@ class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectT
filterset_class = filtersets.LocationFilterSet
-class ManufacturerType(OrganizationalObjectType):
+class ManufacturerType(OrganizationalObjectType, ContactsMixin):
class Meta:
model = models.Manufacturer
@@ -328,6 +342,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):
@@ -373,7 +390,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType):
return self.type or None
-class PowerPanelType(NetBoxObjectType):
+class PowerPanelType(NetBoxObjectType, ContactsMixin):
class Meta:
model = models.PowerPanel
@@ -403,7 +420,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType):
return self.type or None
-class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
class Meta:
model = models.Rack
@@ -416,6 +433,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):
@@ -449,7 +469,7 @@ class RearPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.RearPortTemplateFilterSet
-class RegionType(VLANGroupsMixin, OrganizationalObjectType):
+class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
class Meta:
model = models.Region
@@ -457,7 +477,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType):
filterset_class = filtersets.RegionFilterSet
-class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
+class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType):
asn = graphene.Field(BigInt)
class Meta:
@@ -466,7 +486,7 @@ class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType):
filterset_class = filtersets.SiteFilterSet
-class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType):
+class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType):
class Meta:
model = models.SiteGroup
@@ -480,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType):
model = models.VirtualChassis
fields = '__all__'
filterset_class = filtersets.VirtualChassisFilterSet
+
+
+class VirtualDeviceContextType(NetBoxObjectType):
+
+ class Meta:
+ model = models.VirtualDeviceContext
+ fields = '__all__'
+ filterset_class = filtersets.VirtualDeviceContextFilterSet
diff --git a/netbox/dcim/migrations/0001_squashed.py b/netbox/dcim/migrations/0001_squashed.py
index 374d3bf45..3d7156e17 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)),
@@ -195,7 +195,7 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.CreateModel(
@@ -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,14 +345,14 @@ 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)),
('description', models.CharField(blank=True, max_length=200)),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.CreateModel(
@@ -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)),
@@ -369,7 +369,7 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.CreateModel(
@@ -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)),
@@ -538,7 +538,7 @@ class Migration(migrations.Migration):
('description', models.CharField(blank=True, max_length=200)),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.CreateModel(
@@ -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..4b6c27450 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)),
@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
- 'ordering': ['name'],
+ 'ordering': ('name',),
},
),
migrations.AddField(
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_weight_fields.py b/netbox/dcim/migrations/0163_weight_fields.py
new file mode 100644
index 000000000..ddcc01164
--- /dev/null
+++ b/netbox/dcim/migrations/0163_weight_fields.py
@@ -0,0 +1,72 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0162_unique_constraints'),
+ ]
+
+ operations = [
+
+ # Device types
+ 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='devicetype',
+ name='_abs_weight',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+
+ # Module types
+ 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='moduletype',
+ name='_abs_weight',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+
+ # Racks
+ 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='max_weight',
+ field=models.PositiveIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='rack',
+ 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='_abs_max_weight',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0164_rack_mounting_depth.py b/netbox/dcim/migrations/0164_rack_mounting_depth.py
new file mode 100644
index 000000000..96836e0d6
--- /dev/null
+++ b/netbox/dcim/migrations/0164_rack_mounting_depth.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.1 on 2022-10-27 14:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0163_weight_fields'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='rack',
+ name='mounting_depth',
+ field=models.PositiveSmallIntegerField(blank=True, null=True),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0165_standardize_description_comments.py b/netbox/dcim/migrations/0165_standardize_description_comments.py
new file mode 100644
index 000000000..f17f1d321
--- /dev/null
+++ b/netbox/dcim/migrations/0165_standardize_description_comments.py
@@ -0,0 +1,78 @@
+# Generated by Django 4.1.2 on 2022-11-03 18:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0164_rack_mounting_depth'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cable',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='cable',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='device',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='devicetype',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='module',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='moduletype',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='powerfeed',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='powerpanel',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='powerpanel',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='rack',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ migrations.AddField(
+ model_name='rackreservation',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='virtualchassis',
+ name='comments',
+ field=models.TextField(blank=True),
+ ),
+ migrations.AddField(
+ model_name='virtualchassis',
+ name='description',
+ field=models.CharField(blank=True, max_length=200),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0166_virtualdevicecontext.py b/netbox/dcim/migrations/0166_virtualdevicecontext.py
new file mode 100644
index 000000000..05becbdc6
--- /dev/null
+++ b/netbox/dcim/migrations/0166_virtualdevicecontext.py
@@ -0,0 +1,54 @@
+# Generated by Django 4.1.2 on 2022-11-10 16:56
+
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ipam', '0063_standardize_description_comments'),
+ ('extras', '0082_savedfilter'),
+ ('tenancy', '0009_standardize_description_comments'),
+ ('dcim', '0165_standardize_description_comments'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VirtualDeviceContext',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(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=utilities.json.CustomFieldJSONEncoder)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('name', models.CharField(max_length=64)),
+ ('status', models.CharField(max_length=50)),
+ ('identifier', models.PositiveSmallIntegerField(blank=True, null=True)),
+ ('comments', models.TextField(blank=True)),
+ ('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')),
+ ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
+ ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')),
+ ],
+ options={
+ 'ordering': ['name'],
+ },
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='vdcs',
+ field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'),
+ ),
+ migrations.AddConstraint(
+ model_name='virtualdevicecontext',
+ constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifier'),
+ ),
+ migrations.AddConstraint(
+ model_name='virtualdevicecontext',
+ constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_device_name'),
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0167_module_status.py b/netbox/dcim/migrations/0167_module_status.py
new file mode 100644
index 000000000..c048b4bd8
--- /dev/null
+++ b/netbox/dcim/migrations/0167_module_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-12-09 15:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0166_virtualdevicecontext'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='module',
+ name='status',
+ field=models.CharField(default='active', max_length=50),
+ ),
+ ]
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index e05eb6d51..48c1f92db 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -12,8 +12,8 @@ from django.urls import reverse
from dcim.choices import *
from dcim.constants import *
from dcim.fields import PathField
-from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
-from netbox.models import NetBoxModel
+from dcim.utils import decompile_path_node, object_to_path_node
+from netbox.models import PrimaryModel
from utilities.fields import ColorField
from utilities.querysets import RestrictedQuerySet
from utilities.utils import to_meters
@@ -34,7 +34,7 @@ trace_paths = Signal()
# Cables
#
-class Cable(NetBoxModel):
+class Cable(PrimaryModel):
"""
A physical connection between two endpoints.
"""
@@ -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'
),
)
@@ -279,6 +279,17 @@ class CableTermination(models.Model):
def clean(self):
super().clean()
+ # Check for existing termination
+ existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
+ termination_type=self.termination_type,
+ termination_id=self.termination_id
+ ).first()
+ if existing_termination is not None:
+ raise ValidationError(
+ f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
+ f"{self.termination_id}: cable {existing_termination.cable.pk}"
+ )
+
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
@@ -556,11 +567,12 @@ class CablePath(models.Model):
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
- term_side = remote_terminations[0].term_side
- assert all(ct.term_side == term_side for ct in remote_terminations[1:])
+ if len(remote_terminations) > 1:
+ is_split = True
+ break
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
- term_side='Z' if term_side == 'A' else 'A'
+ term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
@@ -570,6 +582,7 @@ class CablePath(models.Model):
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
+ is_complete = True
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
@@ -673,6 +686,7 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
+ from circuits.models import CircuitTermination
nodes = self.path_objects[-1]
# RearPort splitting to multiple FrontPorts with no stack position
@@ -682,3 +696,8 @@ class CablePath(models.Model):
# RearPorts connected to different cables
elif type(nodes[0]) is FrontPort:
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])
+ # Cable terminating to multiple CircuitTerminations
+ elif type(nodes[0]) is CircuitTermination:
+ return [
+ ct.get_peer_termination() for ct in nodes
+ ]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index b7079d375..3b136987d 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -3,6 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.utils.translation import gettext as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -52,7 +53,7 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
label = models.CharField(
max_length=64,
blank=True,
- help_text="Physical label"
+ help_text=_("Physical label")
)
description = models.CharField(
max_length=200,
@@ -61,6 +62,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 +108,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 +164,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 +193,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')),
@@ -218,24 +223,17 @@ class PowerPortTemplate(ModularComponentTemplateModel):
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text="Maximum power draw (watts)"
+ help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text="Allocated power draw (watts)"
+ help_text=_("Allocated power draw (watts)")
)
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')),
@@ -286,18 +284,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
- help_text="Phase (for three-phase feeds)"
+ help_text=_("Phase (for three-phase feeds)")
)
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 +363,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 +412,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 +499,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')),
@@ -542,15 +527,11 @@ class ModuleBayTemplate(ComponentTemplateModel):
position = models.CharField(
max_length=30,
blank=True,
- help_text='Identifier to reference when renaming installed components'
+ help_text=_('Identifier to reference when renaming installed components')
)
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 +555,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,
@@ -645,7 +622,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
max_length=50,
verbose_name='Part ID',
blank=True,
- help_text='Manufacturer-assigned part identifier'
+ help_text=_('Manufacturer-assigned part identifier')
)
objects = TreeManager()
@@ -653,7 +630,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 838336e21..26a6ade98 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -7,6 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
from django.urls import reverse
+from django.utils.translation import gettext as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -60,7 +61,7 @@ class ComponentModel(NetBoxModel):
label = models.CharField(
max_length=64,
blank=True,
- help_text="Physical label"
+ help_text=_("Physical label")
)
description = models.CharField(
max_length=200,
@@ -69,6 +70,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 +107,7 @@ class ModularComponentModel(ComponentModel):
object_id_field='component_id'
)
- class Meta:
+ class Meta(ComponentModel.Meta):
abstract = True
@@ -122,7 +130,7 @@ class CabledObjectModel(models.Model):
)
mark_connected = models.BooleanField(
default=False,
- help_text="Treat as if a cable is connected"
+ help_text=_("Treat as if a cable is connected")
)
cable_terminations = GenericRelation(
@@ -189,7 +197,7 @@ class PathEndpoint(models.Model):
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
- `connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
+ `connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
"""
_path = models.ForeignKey(
to='dcim.CablePath',
@@ -254,21 +262,17 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
- help_text='Physical port type'
+ help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
- help_text='Port speed in bits per second'
+ help_text=_('Port speed in bits per second')
)
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})
@@ -281,21 +285,17 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
- help_text='Physical port type'
+ help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
- help_text='Port speed in bits per second'
+ help_text=_('Port speed in bits per second')
)
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})
@@ -312,27 +312,23 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
max_length=50,
choices=PowerPortTypeChoices,
blank=True,
- help_text='Physical port type'
+ help_text=_('Physical port type')
)
maximum_draw = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text="Maximum power draw (watts)"
+ help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
- help_text="Allocated power draw (watts)"
+ help_text=_("Allocated power draw (watts)")
)
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})
@@ -425,7 +421,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
max_length=50,
choices=PowerOutletTypeChoices,
blank=True,
- help_text='Physical port type'
+ help_text=_('Physical port type')
)
power_port = models.ForeignKey(
to='dcim.PowerPort',
@@ -438,15 +434,11 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
- help_text="Phase (for three-phase feeds)"
+ help_text=_("Phase (for three-phase feeds)")
)
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})
@@ -540,6 +532,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
max_length=100,
blank=True
)
+ vdcs = models.ManyToManyField(
+ to='dcim.VirtualDeviceContext',
+ related_name='interfaces'
+ )
lag = models.ForeignKey(
to='self',
on_delete=models.SET_NULL,
@@ -555,7 +551,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
mgmt_only = models.BooleanField(
default=False,
verbose_name='Management only',
- help_text='This interface is used only for out-of-band management'
+ help_text=_('This interface is used only for out-of-band management')
)
speed = models.PositiveIntegerField(
blank=True,
@@ -572,7 +568,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
null=True,
blank=True,
verbose_name='WWN',
- help_text='64-bit World Wide Name'
+ help_text=_('64-bit World Wide Name')
)
rf_role = models.CharField(
max_length=30,
@@ -677,9 +673,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 +890,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):
@@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
def clean(self):
super().clean()
- # Validate rear port assignment
- if self.rear_port.device != self.device:
- raise ValidationError({
- "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
- })
+ if hasattr(self, 'rear_port'):
- # Validate rear port position assignment
- if self.rear_port_position > self.rear_port.positions:
- raise ValidationError({
- "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
- f"{self.rear_port.name} has only {self.rear_port.positions} positions"
- })
+ # Validate rear port assignment
+ if self.rear_port.device != self.device:
+ raise ValidationError({
+ "rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
+ })
+
+ # Validate rear port position assignment
+ if self.rear_port_position > self.rear_port.positions:
+ raise ValidationError({
+ "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
+ f"{self.rear_port.name} has only {self.rear_port.positions} positions"
+ })
class RearPort(ModularComponentModel, CabledObjectModel):
@@ -942,10 +944,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})
@@ -953,12 +951,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})"
+ })
#
@@ -972,15 +971,11 @@ class ModuleBay(ComponentModel):
position = models.CharField(
max_length=30,
blank=True,
- help_text='Identifier to reference when renaming installed components'
+ help_text=_('Identifier to reference when renaming installed components')
)
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})
@@ -999,10 +994,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})
@@ -1039,27 +1030,9 @@ class InventoryItemRole(OrganizationalModel):
"""
Inventory items may optionally be assigned a functional role.
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
color = ColorField(
default=ColorChoices.COLOR_GREY
)
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
def get_absolute_url(self):
return reverse('dcim:inventoryitemrole', args=[self.pk])
@@ -1112,7 +1085,7 @@ class InventoryItem(MPTTModel, ComponentModel):
max_length=50,
verbose_name='Part ID',
blank=True,
- help_text='Manufacturer-assigned part identifier'
+ help_text=_('Manufacturer-assigned part identifier')
)
serial = models.CharField(
max_length=50,
@@ -1125,11 +1098,11 @@ class InventoryItem(MPTTModel, ComponentModel):
blank=True,
null=True,
verbose_name='Asset tag',
- help_text='A unique tag used to identify this item'
+ help_text=_('A unique tag used to identify this item')
)
discovered = models.BooleanField(
default=False,
- help_text='This item was automatically discovered'
+ help_text=_('This item was automatically discovered')
)
objects = TreeManager()
@@ -1138,7 +1111,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})
@@ -1151,3 +1129,25 @@ class InventoryItem(MPTTModel, ComponentModel):
raise ValidationError({
"parent": "Cannot assign self as parent."
})
+
+ # Validation for moving InventoryItems
+ if self.pk:
+ # Cannot move an InventoryItem to another device if it has a parent
+ if self.parent and self.parent.device != self.device:
+ raise ValidationError({
+ "parent": "Parent inventory item does not belong to the same device."
+ })
+
+ # Prevent moving InventoryItems with children
+ first_child = self.get_children().first()
+ if first_child and first_child.device != self.device:
+ raise ValidationError("Cannot move an inventory item with dependent children")
+
+ # When moving an InventoryItem to another device, remove any associated component
+ if self.component and self.component.device != self.device:
+ self.component = None
+ else:
+ if self.component and self.component.device != self.device:
+ raise ValidationError({
+ "device": "Cannot assign inventory item to component on another device"
+ })
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index ccf4613bf..603129228 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -1,25 +1,29 @@
import decimal
-
import yaml
-from django.apps import apps
+from functools import cached_property
+
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.db.models.signals import post_save
from django.urls import reverse
from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
-from netbox.models import OrganizationalModel, NetBoxModel
+from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from .device_components import *
+from .mixins import WeightMixin
__all__ = (
@@ -31,6 +35,7 @@ __all__ = (
'ModuleType',
'Platform',
'VirtualChassis',
+ 'VirtualDeviceContext',
)
@@ -42,35 +47,16 @@ class Manufacturer(OrganizationalModel):
"""
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
# Generic relations
contacts = GenericRelation(
to='tenancy.ContactAssignment'
)
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
-
def get_absolute_url(self):
return reverse('dcim:manufacturer', args=[self.pk])
-class DeviceType(NetBoxModel):
+class DeviceType(PrimaryModel, 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).
@@ -99,7 +85,7 @@ class DeviceType(NetBoxModel):
part_number = models.CharField(
max_length=50,
blank=True,
- help_text='Discrete part number (optional)'
+ help_text=_('Discrete part number (optional)')
)
u_height = models.DecimalField(
max_digits=4,
@@ -110,15 +96,15 @@ class DeviceType(NetBoxModel):
is_full_depth = models.BooleanField(
default=True,
verbose_name='Is full depth',
- help_text='Device consumes both front and rear rack faces'
+ help_text=_('Device consumes both front and rear rack faces')
)
subdevice_role = models.CharField(
max_length=50,
choices=SubdeviceRoleChoices,
blank=True,
verbose_name='Parent/child status',
- help_text='Parent devices house child devices in device bays. Leave blank '
- 'if this device type is neither a parent nor a child.'
+ help_text=_('Parent devices house child devices in device bays. Leave blank '
+ 'if this device type is neither a parent nor a child.')
)
airflow = models.CharField(
max_length=50,
@@ -133,20 +119,26 @@ class DeviceType(NetBoxModel):
upload_to='devicetype-images',
blank=True
)
- comments = models.TextField(
- blank=True
- )
clone_fields = (
- 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
+ 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
+ )
+ prerequisite_models = (
+ 'dcim.Manufacturer',
)
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
@@ -161,10 +153,6 @@ class DeviceType(NetBoxModel):
self._original_front_image = self.front_image
self._original_rear_image = self.rear_image
- @classmethod
- def get_prerequisite_models(cls):
- return [Manufacturer, ]
-
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@@ -268,7 +256,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 +296,7 @@ class DeviceType(NetBoxModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
-class ModuleType(NetBoxModel):
+class ModuleType(PrimaryModel, 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
@@ -326,10 +314,7 @@ class ModuleType(NetBoxModel):
part_number = models.CharField(
max_length=50,
blank=True,
- help_text='Discrete part number (optional)'
- )
- comments = models.TextField(
- blank=True
+ help_text=_('Discrete part number (optional)')
)
# Generic relations
@@ -337,21 +322,23 @@ class ModuleType(NetBoxModel):
to='extras.ImageAttachment'
)
- clone_fields = ('manufacturer',)
+ clone_fields = ('manufacturer', 'weight', 'weight_unit',)
+ prerequisite_models = (
+ 'dcim.Manufacturer',
+ )
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):
return self.model
- @classmethod
- def get_prerequisite_models(cls):
- return [Manufacturer, ]
-
def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk])
@@ -406,32 +393,14 @@ class DeviceRole(OrganizationalModel):
color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to
virtual machines as well.
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
color = ColorField(
default=ColorChoices.COLOR_GREY
)
vm_role = models.BooleanField(
default=True,
verbose_name='VM Role',
- help_text='Virtual machines may be assigned to this role'
+ help_text=_('Virtual machines may be assigned to this role')
)
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
def get_absolute_url(self):
return reverse('dcim:devicerole', args=[self.pk])
@@ -443,50 +412,32 @@ class Platform(OrganizationalModel):
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver.
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='platforms',
blank=True,
null=True,
- help_text='Optionally limit this platform to devices of a certain manufacturer'
+ help_text=_('Optionally limit this platform to devices of a certain manufacturer')
)
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
- help_text='The name of the NAPALM driver to use when interacting with devices'
+ help_text=_('The name of the NAPALM driver to use when interacting with devices')
)
napalm_args = models.JSONField(
blank=True,
null=True,
verbose_name='NAPALM arguments',
- help_text='Additional arguments to pass when initiating the NAPALM driver (JSON format)'
+ help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
)
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
-class Device(NetBoxModel, ConfigContextModel):
+class Device(PrimaryModel, ConfigContextModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@@ -544,7 +495,7 @@ class Device(NetBoxModel, ConfigContextModel):
null=True,
unique=True,
verbose_name='Asset tag',
- help_text='A unique tag used to identify this device'
+ help_text=_('A unique tag used to identify this device')
)
site = models.ForeignKey(
to='dcim.Site',
@@ -572,7 +523,7 @@ class Device(NetBoxModel, ConfigContextModel):
null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
verbose_name='Position (U)',
- help_text='The lowest-numbered unit occupied by the device'
+ help_text=_('The lowest-numbered unit occupied by the device')
)
face = models.CharField(
max_length=50,
@@ -630,9 +581,6 @@ class Device(NetBoxModel, ConfigContextModel):
null=True,
validators=[MaxValueValidator(255)]
)
- comments = models.TextField(
- blank=True
- )
# Generic relations
contacts = GenericRelation(
@@ -648,13 +596,33 @@ class Device(NetBoxModel, ConfigContextModel):
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'cluster', 'virtual_chassis',
)
+ prerequisite_models = (
+ 'dcim.Site',
+ 'dcim.DeviceRole',
+ 'dcim.DeviceType',
+ )
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):
@@ -672,30 +640,9 @@ class Device(NetBoxModel, ConfigContextModel):
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
- @classmethod
- def get_prerequisite_models(cls):
- return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
-
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()
@@ -821,6 +768,32 @@ class Device(NetBoxModel, ConfigContextModel):
'vc_position': "A device assigned to a virtual chassis must have its position defined."
})
+ def _instantiate_components(self, queryset, bulk_create=True):
+ """
+ Instantiate components for the device from the specified component templates.
+
+ Args:
+ bulk_create: If True, bulk_create() will be called to create all components in a single query
+ (default). Otherwise, save() will be called on each instance individually.
+ """
+ components = [obj.instantiate(device=self) for obj in queryset]
+ if components and bulk_create:
+ model = components[0]._meta.model
+ model.objects.bulk_create(components)
+ # Manually send the post_save signal for each of the newly created components
+ for component in components:
+ post_save.send(
+ sender=model,
+ instance=component,
+ created=True,
+ raw=False,
+ using='default',
+ update_fields=None
+ )
+ elif components:
+ for component in components:
+ component.save()
+
def save(self, *args, **kwargs):
is_new = not bool(self.pk)
@@ -830,38 +803,19 @@ class Device(NetBoxModel, ConfigContextModel):
super().save(*args, **kwargs)
- # If this is a new Device, instantiate all of the related components per the DeviceType definition
+ # If this is a new Device, instantiate all the related components per the DeviceType definition
if is_new:
- ConsolePort.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()]
- )
- ConsoleServerPort.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()]
- )
- PowerPort.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()]
- )
- PowerOutlet.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()]
- )
- Interface.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()]
- )
- RearPort.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()]
- )
- FrontPort.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()]
- )
- ModuleBay.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()]
- )
- DeviceBay.objects.bulk_create(
- [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()]
- )
- # Avoid bulk_create to handle MPTT
- for x in self.device_type.inventoryitemtemplates.all():
- x.instantiate(device=self).save()
+ self._instantiate_components(self.device_type.consoleporttemplates.all())
+ self._instantiate_components(self.device_type.consoleserverporttemplates.all())
+ self._instantiate_components(self.device_type.powerporttemplates.all())
+ self._instantiate_components(self.device_type.poweroutlettemplates.all())
+ self._instantiate_components(self.device_type.interfacetemplates.all())
+ self._instantiate_components(self.device_type.rearporttemplates.all())
+ self._instantiate_components(self.device_type.frontporttemplates.all())
+ self._instantiate_components(self.device_type.modulebaytemplates.all())
+ self._instantiate_components(self.device_type.devicebaytemplates.all())
+ # Disable bulk_create to accommodate MPTT
+ self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
# Update Site and Rack assignment for any child Devices
devices = Device.objects.filter(parent_bay__device=self)
@@ -938,8 +892,20 @@ 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):
+
+class Module(PrimaryModel, ConfigContextModel):
"""
A Module represents a field-installable component within a Device which may itself hold multiple device components
(for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
@@ -959,6 +925,11 @@ class Module(NetBoxModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='instances'
)
+ status = models.CharField(
+ max_length=50,
+ choices=ModuleStatusChoices,
+ default=ModuleStatusChoices.STATUS_ACTIVE
+ )
serial = models.CharField(
max_length=50,
blank=True,
@@ -970,13 +941,10 @@ class Module(NetBoxModel, ConfigContextModel):
null=True,
unique=True,
verbose_name='Asset tag',
- help_text='A unique tag used to identify this device'
- )
- comments = models.TextField(
- blank=True
+ help_text=_('A unique tag used to identify this device')
)
- clone_fields = ('device', 'module_type')
+ clone_fields = ('device', 'module_type', 'status')
class Meta:
ordering = ('module_bay',)
@@ -987,6 +955,17 @@ class Module(NetBoxModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:module', args=[self.pk])
+ def get_status_color(self):
+ return ModuleStatusChoices.colors.get(self.status)
+
+ def clean(self):
+ super().clean()
+
+ if hasattr(self, "module_bay") and (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
@@ -1015,7 +994,8 @@ class Module(NetBoxModel, ConfigContextModel):
# Prefetch installed components
installed_components = {
- component.name: component for component in getattr(self.device, component_attribute).filter(module__isnull=True)
+ component.name: component
+ for component in getattr(self.device, component_attribute).filter(module__isnull=True)
}
# Get the template for the module type.
@@ -1037,14 +1017,36 @@ class Module(NetBoxModel, ConfigContextModel):
create_instances.append(template_instance)
component_model.objects.bulk_create(create_instances)
- component_model.objects.bulk_update(update_instances, ['module'])
+ # Emit the post_save signal for each newly created object
+ for component in create_instances:
+ post_save.send(
+ sender=component_model,
+ instance=component,
+ created=True,
+ raw=False,
+ using='default',
+ update_fields=None
+ )
+
+ update_fields = ['module']
+ component_model.objects.bulk_update(update_instances, update_fields)
+ # Emit the post_save signal for each updated object
+ for component in update_instances:
+ post_save.send(
+ sender=component_model,
+ instance=component,
+ created=False,
+ raw=False,
+ using='default',
+ update_fields=update_fields
+ )
#
# Virtual chassis
#
-class VirtualChassis(NetBoxModel):
+class VirtualChassis(PrimaryModel):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
"""
@@ -1099,3 +1101,101 @@ class VirtualChassis(NetBoxModel):
)
return super().delete(*args, **kwargs)
+
+
+class VirtualDeviceContext(PrimaryModel):
+ device = models.ForeignKey(
+ to='Device',
+ on_delete=models.PROTECT,
+ related_name='vdcs',
+ blank=True,
+ null=True
+ )
+ name = models.CharField(
+ max_length=64
+ )
+ status = models.CharField(
+ max_length=50,
+ choices=VirtualDeviceContextStatusChoices,
+ )
+ identifier = models.PositiveSmallIntegerField(
+ help_text='Numeric identifier unique to the parent device',
+ blank=True,
+ null=True,
+ )
+ primary_ip4 = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True,
+ verbose_name='Primary IPv4'
+ )
+ primary_ip6 = models.OneToOneField(
+ to='ipam.IPAddress',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True,
+ verbose_name='Primary IPv6'
+ )
+ tenant = models.ForeignKey(
+ to='tenancy.Tenant',
+ on_delete=models.PROTECT,
+ related_name='vdcs',
+ blank=True,
+ null=True
+ )
+ comments = models.TextField(
+ blank=True
+ )
+
+ class Meta:
+ ordering = ['name']
+ constraints = (
+ models.UniqueConstraint(
+ fields=('device', 'identifier',),
+ name='%(app_label)s_%(class)s_device_identifier'
+ ),
+ models.UniqueConstraint(
+ fields=('device', 'name',),
+ name='%(app_label)s_%(class)s_device_name'
+ ),
+ )
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk})
+
+ def get_status_color(self):
+ return VirtualDeviceContextStatusChoices.colors.get(self.status)
+
+ @property
+ def primary_ip(self):
+ if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
+ return self.primary_ip4
+ elif self.primary_ip6:
+ return self.primary_ip6
+ elif self.primary_ip4:
+ return self.primary_ip4
+ else:
+ return None
+
+ def clean(self):
+ super().clean()
+
+ # Validate primary IPv4/v6 assignment
+ for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)):
+ if not primary_ip:
+ continue
+ if primary_ip.family != family:
+ raise ValidationError({
+ f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
+ })
+ device_interfaces = self.device.vc_interfaces(if_master=False)
+ if primary_ip.assigned_object not in device_interfaces:
+ raise ValidationError({
+ f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
+ })
diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py
new file mode 100644
index 000000000..486945b0f
--- /dev/null
+++ b/netbox/dcim/models/mixins.py
@@ -0,0 +1,43 @@
+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 and not self.weight_unit:
+ raise ValidationError("Must specify a unit when setting a weight")
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index 83eead67f..3377a9edb 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -1,14 +1,13 @@
-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.urls import reverse
+from django.utils.translation import gettext as _
from dcim.choices import *
-from dcim.constants import *
from netbox.config import ConfigItem
-from netbox.models import NetBoxModel
+from netbox.models import PrimaryModel
from utilities.validators import ExclusionValidator
from .device_components import CabledObjectModel, PathEndpoint
@@ -22,7 +21,7 @@ __all__ = (
# Power
#
-class PowerPanel(NetBoxModel):
+class PowerPanel(PrimaryModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
"""
@@ -48,17 +47,22 @@ class PowerPanel(NetBoxModel):
to='extras.ImageAttachment'
)
+ prerequisite_models = (
+ 'dcim.Site',
+ )
+
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
- @classmethod
- def get_prerequisite_models(cls):
- return [apps.get_model('dcim.Site'), ]
-
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
@@ -72,7 +76,7 @@ class PowerPanel(NetBoxModel):
)
-class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
+class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
"""
An electrical circuit delivered from a PowerPanel.
"""
@@ -121,32 +125,33 @@ class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel):
max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
- help_text="Maximum permissible draw (percentage)"
+ help_text=_("Maximum permissible draw (percentage)")
)
available_power = models.PositiveIntegerField(
default=0,
editable=False
)
- comments = models.TextField(
- blank=True
- )
clone_fields = (
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization',
)
+ prerequisite_models = (
+ 'dcim.PowerPanel',
+ )
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
- @classmethod
- def get_prerequisite_models(cls):
- return [PowerPanel, ]
-
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 20027675a..03be2fdb3 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -1,25 +1,26 @@
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 django.utils.translation import gettext as _
from dcim.choices import *
from dcim.constants import *
from dcim.svg import RackElevationSVG
-from netbox.models import OrganizationalModel, NetBoxModel
+from netbox.models import OrganizationalModel, PrimaryModel
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 utilities.utils import array_to_string, drange, to_grams
+from .device_components import PowerPort
+from .devices import Device, Module
+from .mixins import WeightMixin
from .power import PowerFeed
__all__ = (
@@ -37,33 +38,15 @@ class RackRole(OrganizationalModel):
"""
Racks can be organized by functional role, similar to Devices.
"""
- name = models.CharField(
- max_length=100,
- unique=True
- )
- slug = models.SlugField(
- max_length=100,
- unique=True
- )
color = ColorField(
default=ColorChoices.COLOR_GREY
)
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- class Meta:
- ordering = ['name']
-
- def __str__(self):
- return self.name
def get_absolute_url(self):
return reverse('dcim:rackrole', args=[self.pk])
-class Rack(NetBoxModel):
+class Rack(PrimaryModel, 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.
@@ -81,7 +64,7 @@ class Rack(NetBoxModel):
blank=True,
null=True,
verbose_name='Facility ID',
- help_text='Locally-assigned identifier'
+ help_text=_('Locally-assigned identifier')
)
site = models.ForeignKey(
to='dcim.Site',
@@ -113,7 +96,7 @@ class Rack(NetBoxModel):
related_name='racks',
blank=True,
null=True,
- help_text='Functional role'
+ help_text=_('Functional role')
)
serial = models.CharField(
max_length=50,
@@ -126,7 +109,7 @@ class Rack(NetBoxModel):
null=True,
unique=True,
verbose_name='Asset tag',
- help_text='A unique tag used to identify this rack'
+ help_text=_('A unique tag used to identify this rack')
)
type = models.CharField(
choices=RackTypeChoices,
@@ -138,36 +121,51 @@ class Rack(NetBoxModel):
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name='Width',
- help_text='Rail-to-rail width'
+ help_text=_('Rail-to-rail width')
)
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)],
- help_text='Height in rack units'
+ help_text=_('Height in rack units')
)
desc_units = models.BooleanField(
default=False,
verbose_name='Descending units',
- help_text='Units are numbered top-to-bottom'
+ help_text=_('Units are numbered top-to-bottom')
)
outer_width = models.PositiveSmallIntegerField(
blank=True,
null=True,
- help_text='Outer dimension of rack (width)'
+ help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
blank=True,
null=True,
- help_text='Outer dimension of rack (depth)'
+ help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
max_length=50,
choices=RackDimensionUnitChoices,
blank=True,
)
- comments = models.TextField(
- blank=True
+ max_weight = models.PositiveIntegerField(
+ blank=True,
+ null=True,
+ help_text=_('Maximum load capacity for the rack')
+ )
+ # Stores the normalized max weight (in grams) for database ordering
+ _abs_max_weight = models.PositiveBigIntegerField(
+ blank=True,
+ null=True
+ )
+ mounting_depth = models.PositiveSmallIntegerField(
+ blank=True,
+ null=True,
+ help_text=(
+ _('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the '
+ 'distance between the front and rear rails.')
+ )
)
# Generic relations
@@ -186,15 +184,24 @@ 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', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
+ )
+ prerequisite_models = (
+ 'dcim.Site',
)
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):
@@ -202,10 +209,6 @@ class Rack(NetBoxModel):
return f'{self.name} ({self.facility_id})'
return self.name
- @classmethod
- def get_prerequisite_models(cls):
- return [apps.get_model('dcim.Site'), ]
-
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@@ -222,6 +225,10 @@ class Rack(NetBoxModel):
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
+ # Validate max_weight and weight_unit
+ if self.max_weight and not self.weight_unit:
+ raise ValidationError("Must specify a unit when setting a maximum weight")
+
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(
@@ -244,6 +251,16 @@ class Rack(NetBoxModel):
'location': f"Location must be from the same site, {self.site}."
})
+ def save(self, *args, **kwargs):
+
+ # Store the given max weight (if any) in grams for use in database ordering
+ if self.max_weight and self.weight_unit:
+ self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
+ else:
+ self._abs_max_weight = None
+
+ super().save(*args, **kwargs)
+
@property
def units(self):
"""
@@ -449,8 +466,24 @@ 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):
+
+class RackReservation(PrimaryModel):
"""
One or more reserved units within a Rack.
"""
@@ -477,16 +510,17 @@ class RackReservation(NetBoxModel):
max_length=200
)
+ clone_fields = ('rack', 'user', 'tenant')
+ prerequisite_models = (
+ 'dcim.Rack',
+ )
+
class Meta:
ordering = ['created', 'pk']
def __str__(self):
return "Reservation for rack {}".format(self.rack)
- @classmethod
- def get_prerequisite_models(cls):
- return [apps.get_model('dcim.Site'), Rack, ]
-
def get_absolute_url(self):
return reverse('dcim:rackreservation', args=[self.pk])
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index f5c8e6d9d..c035fc1db 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -2,12 +2,12 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
-from mptt.models import TreeForeignKey
+from django.utils.translation import gettext as _
from timezone_field import TimeZoneField
from dcim.choices import *
from dcim.constants import *
-from netbox.models import NestedGroupModel, NetBoxModel
+from netbox.models import NestedGroupModel, PrimaryModel
from utilities.fields import NaturalOrderingField
__all__ = (
@@ -28,25 +28,6 @@ class Region(NestedGroupModel):
states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are
also considered to be members of its parent and ancestor region(s).
"""
- parent = TreeForeignKey(
- to='self',
- on_delete=models.CASCADE,
- related_name='children',
- blank=True,
- null=True,
- db_index=True
- )
- name = models.CharField(
- max_length=100
- )
- slug = models.SlugField(
- max_length=100
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
# Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
@@ -62,38 +43,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])
@@ -114,25 +83,6 @@ class SiteGroup(NestedGroupModel):
within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be
nested recursively to form a hierarchy.
"""
- parent = TreeForeignKey(
- to='self',
- on_delete=models.CASCADE,
- related_name='children',
- blank=True,
- null=True,
- db_index=True
- )
- name = models.CharField(
- max_length=100
- )
- slug = models.SlugField(
- max_length=100
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
-
# Generic relations
vlan_groups = GenericRelation(
to='ipam.VLANGroup',
@@ -148,38 +98,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])
@@ -194,7 +132,7 @@ class SiteGroup(NestedGroupModel):
# Sites
#
-class Site(NetBoxModel):
+class Site(PrimaryModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
field can be used to include an external designation, such as a data center name (e.g. Equinix SV6).
@@ -241,7 +179,7 @@ class Site(NetBoxModel):
facility = models.CharField(
max_length=50,
blank=True,
- help_text='Local facility ID or description'
+ help_text=_('Local facility ID or description')
)
asns = models.ManyToManyField(
to='ipam.ASN',
@@ -251,10 +189,6 @@ class Site(NetBoxModel):
time_zone = TimeZoneField(
blank=True
)
- description = models.CharField(
- max_length=200,
- blank=True
- )
physical_address = models.CharField(
max_length=200,
blank=True
@@ -268,17 +202,14 @@ class Site(NetBoxModel):
decimal_places=6,
blank=True,
null=True,
- help_text='GPS coordinate (latitude)'
+ help_text=_('GPS coordinate (latitude)')
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
blank=True,
null=True,
- help_text='GPS coordinate (longitude)'
- )
- comments = models.TextField(
- blank=True
+ help_text=_('GPS coordinate (longitude)')
)
# Generic relations
@@ -322,25 +253,11 @@ class Location(NestedGroupModel):
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
site, or a room within a building, for example.
"""
- name = models.CharField(
- max_length=100
- )
- slug = models.SlugField(
- max_length=100
- )
site = models.ForeignKey(
to='dcim.Site',
on_delete=models.CASCADE,
related_name='locations'
)
- parent = TreeForeignKey(
- to='self',
- on_delete=models.CASCADE,
- related_name='children',
- blank=True,
- null=True,
- db_index=True
- )
status = models.CharField(
max_length=50,
choices=LocationStatusChoices,
@@ -353,10 +270,6 @@ class Location(NestedGroupModel):
blank=True,
null=True
)
- description = models.CharField(
- max_length=200,
- blank=True
- )
# Generic relations
vlan_groups = GenericRelation(
@@ -373,48 +286,35 @@ class Location(NestedGroupModel):
)
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
+ prerequisite_models = (
+ 'dcim.Site',
+ )
class Meta:
ordering = ['site', 'name']
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, ]
-
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])
diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py
new file mode 100644
index 000000000..bae4f030f
--- /dev/null
+++ b/netbox/dcim/search.py
@@ -0,0 +1,317 @@
+from netbox.search import SearchIndex, register_search
+from . import models
+
+
+@register_search
+class CableIndex(SearchIndex):
+ model = models.Cable
+ fields = (
+ ('label', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class ConsolePortIndex(SearchIndex):
+ model = models.ConsolePort
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ ('speed', 2000),
+ )
+
+
+@register_search
+class ConsoleServerPortIndex(SearchIndex):
+ model = models.ConsoleServerPort
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ ('speed', 2000),
+ )
+
+
+@register_search
+class DeviceIndex(SearchIndex):
+ model = models.Device
+ fields = (
+ ('asset_tag', 50),
+ ('serial', 60),
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class DeviceBayIndex(SearchIndex):
+ model = models.DeviceBay
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ )
+
+
+@register_search
+class DeviceRoleIndex(SearchIndex):
+ model = models.DeviceRole
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class DeviceTypeIndex(SearchIndex):
+ model = models.DeviceType
+ fields = (
+ ('model', 100),
+ ('part_number', 200),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class FrontPortIndex(SearchIndex):
+ model = models.FrontPort
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ )
+
+
+@register_search
+class InterfaceIndex(SearchIndex):
+ model = models.Interface
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('mac_address', 300),
+ ('wwn', 300),
+ ('description', 500),
+ ('mtu', 2000),
+ ('speed', 2000),
+ )
+
+
+@register_search
+class InventoryItemIndex(SearchIndex):
+ model = models.InventoryItem
+ fields = (
+ ('asset_tag', 50),
+ ('serial', 60),
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ ('part_id', 2000),
+ )
+
+
+@register_search
+class LocationIndex(SearchIndex):
+ model = models.Location
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class ManufacturerIndex(SearchIndex):
+ model = models.Manufacturer
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class ModuleIndex(SearchIndex):
+ model = models.Module
+ fields = (
+ ('asset_tag', 50),
+ ('serial', 60),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class ModuleBayIndex(SearchIndex):
+ model = models.ModuleBay
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ )
+
+
+@register_search
+class ModuleTypeIndex(SearchIndex):
+ model = models.ModuleType
+ fields = (
+ ('model', 100),
+ ('part_number', 200),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class PlatformIndex(SearchIndex):
+ model = models.Platform
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('napalm_driver', 300),
+ ('description', 500),
+ )
+
+
+@register_search
+class PowerFeedIndex(SearchIndex):
+ model = models.PowerFeed
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class PowerOutletIndex(SearchIndex):
+ model = models.PowerOutlet
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ )
+
+
+@register_search
+class PowerPanelIndex(SearchIndex):
+ model = models.PowerPanel
+ fields = (
+ ('name', 100),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class PowerPortIndex(SearchIndex):
+ model = models.PowerPort
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ ('maximum_draw', 2000),
+ ('allocated_draw', 2000),
+ )
+
+
+@register_search
+class RackIndex(SearchIndex):
+ model = models.Rack
+ fields = (
+ ('asset_tag', 50),
+ ('serial', 60),
+ ('name', 100),
+ ('facility_id', 200),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class RackReservationIndex(SearchIndex):
+ model = models.RackReservation
+ fields = (
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class RackRoleIndex(SearchIndex):
+ model = models.RackRole
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class RearPortIndex(SearchIndex):
+ model = models.RearPort
+ fields = (
+ ('name', 100),
+ ('label', 200),
+ ('description', 500),
+ )
+
+
+@register_search
+class RegionIndex(SearchIndex):
+ model = models.Region
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class SiteIndex(SearchIndex):
+ model = models.Site
+ fields = (
+ ('name', 100),
+ ('facility', 100),
+ ('slug', 110),
+ ('description', 500),
+ ('physical_address', 2000),
+ ('shipping_address', 2000),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class SiteGroupIndex(SearchIndex):
+ model = models.SiteGroup
+ fields = (
+ ('name', 100),
+ ('slug', 110),
+ ('description', 500),
+ )
+
+
+@register_search
+class VirtualChassisIndex(SearchIndex):
+ model = models.VirtualChassis
+ fields = (
+ ('name', 100),
+ ('domain', 300),
+ ('description', 500),
+ ('comments', 5000),
+ )
+
+
+@register_search
+class VirtualDeviceContextIndex(SearchIndex):
+ model = models.VirtualDeviceContext
+ fields = (
+ ('name', 100),
+ ('identifier', 300),
+ ('description', 500),
+ ('comments', 5000),
+ )
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index b990daf1a..522bb76c0 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
-from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
+from .models import (
+ Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
+)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()
+
+
+@receiver(post_save, sender=FrontPort)
+def extend_rearport_cable_paths(instance, created, raw, **kwargs):
+ """
+ When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
+ """
+ if created and not raw:
+ rearport = instance.rear_port
+ for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
+ cablepath.retrace()
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index 3872bc4fe..33adef798 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
@@ -166,7 +166,7 @@ class CableTraceSVG:
"""
if hasattr(instance, 'parent_object'):
# Termination
- return 'f0f0f0'
+ return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color
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/cables.py b/netbox/dcim/tables/cables.py
index e5410e42a..6e9d49719 100644
--- a/netbox/dcim/tables/cables.py
+++ b/netbox/dcim/tables/cables.py
@@ -111,6 +111,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
order_by=('_abs_length', 'length_unit')
)
color = columns.ColorColumn()
+ comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:cable_list'
)
@@ -120,7 +121,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable):
fields = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b',
'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color',
- 'length', 'tags', 'created', 'last_updated',
+ 'length', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type',
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index c42731b90..7a2ea50ba 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -1,12 +1,10 @@
import django_tables2 as tables
+from dcim import models
from django_tables2.utils import Accessor
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
-from dcim.models import (
- ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
- InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
-)
from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
from .template_code import *
__all__ = (
@@ -38,6 +36,7 @@ __all__ = (
'PowerPortTable',
'RearPortTable',
'VirtualChassisTable',
+ 'VirtualDeviceContextTable'
)
@@ -92,7 +91,7 @@ class DeviceRoleTable(NetBoxTable):
)
class Meta(NetBoxTable.Meta):
- model = DeviceRole
+ model = models.DeviceRole
fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
'actions', 'created', 'last_updated',
@@ -123,7 +122,7 @@ class PlatformTable(NetBoxTable):
)
class Meta(NetBoxTable.Meta):
- model = Platform
+ model = models.Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions', 'created', 'last_updated',
@@ -137,12 +136,22 @@ class PlatformTable(NetBoxTable):
# Devices
#
-class DeviceTable(TenancyColumnsMixin, NetBoxTable):
+class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
- template_code=DEVICE_LINK
+ template_code=DEVICE_LINK,
+ linkify=True
)
status = columns.ChoiceFieldColumn()
+ region = tables.Column(
+ accessor=Accessor('site__region'),
+ linkify=True
+ )
+ site_group = tables.Column(
+ accessor=Accessor('site__group'),
+ linkify=True,
+ verbose_name='Site Group'
+ )
site = tables.Column(
linkify=True
)
@@ -192,20 +201,17 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
verbose_name='VC Priority'
)
comments = columns.MarkdownColumn()
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:device_list'
)
class Meta(NetBoxTable.Meta):
- model = Device
+ model = models.Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
- 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow',
- 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
- 'contacts', 'tags', 'created', 'last_updated',
+ 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
+ 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
+ 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@@ -215,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
name = tables.TemplateColumn(
- template_code=DEVICE_LINK
+ template_code=DEVICE_LINK,
+ linkify=True
)
status = columns.ChoiceFieldColumn()
site = tables.Column(
@@ -232,7 +239,7 @@ class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
)
class Meta(NetBoxTable.Meta):
- model = Device
+ model = models.Device
fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type')
empty_text = False
@@ -306,7 +313,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ConsolePort
+ model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
@@ -325,7 +332,7 @@ class DeviceConsolePortTable(ConsolePortTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ConsolePort
+ model = models.ConsolePort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
@@ -348,7 +355,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ConsoleServerPort
+ model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
@@ -368,7 +375,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ConsoleServerPort
+ model = models.ConsoleServerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
@@ -391,7 +398,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
)
class Meta(DeviceComponentTable.Meta):
- model = PowerPort
+ model = models.PowerPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
@@ -412,7 +419,7 @@ class DevicePowerPortTable(PowerPortTable):
)
class Meta(DeviceComponentTable.Meta):
- model = PowerPort
+ model = models.PowerPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
@@ -440,7 +447,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
)
class Meta(DeviceComponentTable.Meta):
- model = PowerOutlet
+ model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
@@ -460,7 +467,7 @@ class DevicePowerOutletTable(PowerOutletTable):
)
class Meta(DeviceComponentTable.Meta):
- model = PowerOutlet
+ model = models.PowerOutlet
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
@@ -499,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name='Tagged VLANs'
)
+ def value_ip_addresses(self, value):
+ return ",".join([str(obj.address) for obj in value.all()])
+
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column(
@@ -516,6 +526,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
orderable=False,
verbose_name='Wireless LANs'
)
+ vdcs = columns.ManyToManyColumn(
+ linkify_item=True,
+ verbose_name='VDCs'
+ )
vrf = tables.Column(
linkify=True
)
@@ -524,12 +538,12 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
)
class Meta(DeviceComponentTable.Meta):
- model = Interface
+ model = models.Interface
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
- 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn',
+ 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -558,12 +572,12 @@ class DeviceInterfaceTable(InterfaceTable):
)
class Meta(DeviceComponentTable.Meta):
- model = Interface
+ model = models.Interface
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
- 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
+ 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions',
)
order_by = ('name',)
@@ -597,7 +611,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
)
class Meta(DeviceComponentTable.Meta):
- model = FrontPort
+ model = models.FrontPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
@@ -620,7 +634,7 @@ class DeviceFrontPortTable(FrontPortTable):
)
class Meta(DeviceComponentTable.Meta):
- model = FrontPort
+ model = models.FrontPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
@@ -646,7 +660,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
)
class Meta(DeviceComponentTable.Meta):
- model = RearPort
+ model = models.RearPort
fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
@@ -666,7 +680,7 @@ class DeviceRearPortTable(RearPortTable):
)
class Meta(DeviceComponentTable.Meta):
- model = RearPort
+ model = models.RearPort
fields = (
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'tags', 'actions',
@@ -707,7 +721,7 @@ class DeviceBayTable(DeviceComponentTable):
)
class Meta(DeviceComponentTable.Meta):
- model = DeviceBay
+ model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'device_role', 'device_type', 'installed_device', 'description', 'tags',
'created', 'last_updated',
@@ -728,7 +742,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
)
class Meta(DeviceComponentTable.Meta):
- model = DeviceBay
+ model = models.DeviceBay
fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
)
@@ -757,7 +771,7 @@ class ModuleBayTable(DeviceComponentTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ModuleBay
+ model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
'description', 'tags',
@@ -771,7 +785,7 @@ class DeviceModuleBayTable(ModuleBayTable):
)
class Meta(DeviceComponentTable.Meta):
- model = ModuleBay
+ model = models.ModuleBay
fields = (
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
'description', 'tags', 'actions',
@@ -801,7 +815,7 @@ class InventoryItemTable(DeviceComponentTable):
cable = None # Override DeviceComponentTable
class Meta(NetBoxTable.Meta):
- model = InventoryItem
+ model = models.InventoryItem
fields = (
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
@@ -820,7 +834,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
)
class Meta(NetBoxTable.Meta):
- model = InventoryItem
+ model = models.InventoryItem
fields = (
'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
'description', 'discovered', 'tags', 'actions',
@@ -845,7 +859,7 @@ class InventoryItemRoleTable(NetBoxTable):
)
class Meta(NetBoxTable.Meta):
- model = InventoryItemRole
+ model = models.InventoryItemRole
fields = (
'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions',
)
@@ -868,11 +882,61 @@ class VirtualChassisTable(NetBoxTable):
url_params={'virtual_chassis_id': 'pk'},
verbose_name='Members'
)
+ comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:virtualchassis_list'
)
class Meta(NetBoxTable.Meta):
- model = VirtualChassis
- fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
+ model = models.VirtualChassis
+ fields = (
+ 'pk', 'id', 'name', 'domain', 'master', 'member_count', 'description', 'comments', 'tags', 'created',
+ 'last_updated',
+ )
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
+
+
+class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
+ name = tables.Column(
+ linkify=True
+ )
+ device = tables.TemplateColumn(
+ order_by=('_name',),
+ template_code=DEVICE_LINK,
+ linkify=True
+ )
+ status = columns.ChoiceFieldColumn()
+ primary_ip = tables.Column(
+ linkify=True,
+ order_by=('primary_ip4', 'primary_ip6'),
+ verbose_name='IP Address'
+ )
+ primary_ip4 = tables.Column(
+ linkify=True,
+ verbose_name='IPv4 Address'
+ )
+ primary_ip6 = tables.Column(
+ linkify=True,
+ verbose_name='IPv6 Address'
+ )
+ interface_count = columns.LinkedCountColumn(
+ viewname='dcim:interface_list',
+ url_params={'vdc_id': 'pk'},
+ verbose_name='Interfaces'
+ )
+
+ comments = columns.MarkdownColumn()
+
+ tags = columns.TagColumn(
+ url_name='dcim:vdc_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = models.VirtualDeviceContext
+ fields = (
+ 'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4',
+ 'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated',
+ )
+ default_columns = (
+ 'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
+ )
diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py
index 3ed4d8c08..42d9c7879 100644
--- a/netbox/dcim/tables/devicetypes.py
+++ b/netbox/dcim/tables/devicetypes.py
@@ -1,11 +1,9 @@
import django_tables2 as tables
-from dcim.models import (
- ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
- InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
-)
+from dcim import models
from netbox.tables import NetBoxTable, columns
-from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
+from tenancy.tables import ContactsColumnMixin
+from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
__all__ = (
'ConsolePortTemplateTable',
@@ -27,7 +25,7 @@ __all__ = (
# Manufacturers
#
-class ManufacturerTable(NetBoxTable):
+class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -43,18 +41,15 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms'
)
slug = tables.Column()
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:manufacturer_list'
)
class Meta(NetBoxTable.Meta):
- model = Manufacturer
+ model = models.Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
- 'contacts', 'actions', 'created', 'last_updated',
+ 'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
@@ -85,12 +80,19 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:devicetype_list'
)
+ u_height = columns.TemplateColumn(
+ template_code='{{ value|floatformat }}'
+ )
+ weight = columns.TemplateColumn(
+ template_code=WEIGHT,
+ order_by=('_abs_weight', 'weight_unit')
+ )
class Meta(NetBoxTable.Meta):
- model = DeviceType
+ model = models.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', 'description', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
@@ -120,7 +122,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = ConsolePortTemplate
+ model = models.ConsolePortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -132,7 +134,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = ConsoleServerPortTemplate
+ model = models.ConsoleServerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
@@ -144,7 +146,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = PowerPortTemplate
+ model = models.PowerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
@@ -156,7 +158,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = PowerOutletTemplate
+ model = models.PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
@@ -171,7 +173,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = InterfaceTemplate
+ model = models.InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions')
empty_text = "None"
@@ -187,7 +189,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = FrontPortTemplate
+ model = models.FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
@@ -200,7 +202,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = RearPortTemplate
+ model = models.RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'positions', 'description', 'actions')
empty_text = "None"
@@ -211,7 +213,7 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = ModuleBayTemplate
+ model = models.ModuleBayTemplate
fields = ('pk', 'name', 'label', 'position', 'description', 'actions')
empty_text = "None"
@@ -222,7 +224,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = DeviceBayTemplate
+ model = models.DeviceBayTemplate
fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"
@@ -242,7 +244,7 @@ class InventoryItemTemplateTable(ComponentTemplateTable):
)
class Meta(ComponentTemplateTable.Meta):
- model = InventoryItemTemplate
+ model = models.InventoryItemTemplate
fields = (
'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions',
)
diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py
index e40d7bd80..8e4ca309e 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 WEIGHT
__all__ = (
'ModuleTable',
@@ -26,11 +27,15 @@ class ModuleTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:moduletype_list'
)
+ weight = columns.TemplateColumn(
+ template_code=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', 'description', 'comments', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',
@@ -51,6 +56,7 @@ class ModuleTable(NetBoxTable):
module_type = tables.Column(
linkify=True
)
+ status = columns.ChoiceFieldColumn()
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:module_list'
@@ -59,9 +65,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
- 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
- 'tags',
+ 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
+ 'description', 'comments', 'tags',
)
default_columns = (
- 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
+ 'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
)
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index 6696d516a..feff29e12 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -1,7 +1,9 @@
import django_tables2 as tables
-
from dcim.models import PowerFeed, PowerPanel
+from tenancy.tables import ContactsColumnMixin
+
from netbox.tables import NetBoxTable, columns
+
from .devices import CableTerminationTable
__all__ = (
@@ -14,7 +16,7 @@ __all__ = (
# Power panels
#
-class PowerPanelTable(NetBoxTable):
+class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -29,9 +31,7 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
+ comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:powerpanel_list'
)
@@ -39,7 +39,8 @@ class PowerPanelTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = PowerPanel
fields = (
- 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'description', 'comments', 'tags',
+ 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@@ -78,7 +79,7 @@ class PowerFeedTable(CableTerminationTable):
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
- 'comments', 'tags', 'created', 'last_updated',
+ 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py
index 39553bac0..cb9aae6fd 100644
--- a/netbox/dcim/tables/racks.py
+++ b/netbox/dcim/tables/racks.py
@@ -3,7 +3,8 @@ 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 tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+from .template_code import WEIGHT
__all__ = (
'RackTable',
@@ -37,7 +38,7 @@ class RackRoleTable(NetBoxTable):
# Racks
#
-class RackTable(TenancyColumnsMixin, NetBoxTable):
+class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
order_by=('_name',),
linkify=True
@@ -68,9 +69,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name='Power'
)
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
@@ -82,13 +80,22 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth'
)
+ weight = columns.TemplateColumn(
+ template_code=WEIGHT,
+ order_by=('_abs_weight', 'weight_unit')
+ )
+ max_weight = columns.TemplateColumn(
+ template_code=WEIGHT,
+ order_by=('_abs_max_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', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
+ 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description',
+ 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@@ -120,6 +127,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name='Units'
)
+ comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:rackreservation_list'
)
@@ -127,7 +135,7 @@ class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = RackReservation
fields = (
- 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
- 'actions', 'created', 'last_updated',
+ 'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant',
+ 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py
index 5dc2aa611..a4de18cee 100644
--- a/netbox/dcim/tables/sites.py
+++ b/netbox/dcim/tables/sites.py
@@ -1,8 +1,9 @@
import django_tables2 as tables
-
from dcim.models import Location, Region, Site, SiteGroup
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
from netbox.tables import NetBoxTable, columns
-from tenancy.tables import TenancyColumnsMixin
+
from .template_code import LOCATION_BUTTONS
__all__ = (
@@ -17,7 +18,7 @@ __all__ = (
# Regions
#
-class RegionTable(NetBoxTable):
+class RegionTable(ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:region_list'
)
@@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
# Site groups
#
-class SiteGroupTable(NetBoxTable):
+class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
@@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
# Sites
#
-class SiteTable(TenancyColumnsMixin, NetBoxTable):
+class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
verbose_name='ASN Count'
)
comments = columns.MarkdownColumn()
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:site_list'
)
@@ -107,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
- 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
- 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
- 'contacts', 'tags', 'created', 'last_updated', 'actions',
+ 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
+ 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
+ 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
# Locations
#
-class LocationTable(TenancyColumnsMixin, NetBoxTable):
+class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
- contacts = columns.ManyToManyColumn(
- linkify_item=True
- )
tags = columns.TagColumn(
url_name='dcim:location_list'
)
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index d34003ee5..dd0581ddc 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -15,10 +15,13 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
+WEIGHT = """
+{% load helpers %}
+{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
+"""
+
DEVICE_LINK = """
-
- {{ record.name|default:'Unnamed device' }}
-
+{{ value|default:'Unnamed device' }}
"""
DEVICEBAY_STATUS = """
@@ -239,7 +242,7 @@ INTERFACE_BUTTONS = """
Inventory Item
{% endif %}
{% if perms.dcim.add_interface %}
- Child Interface
+ Child Interface
{% endif %}
{% if perms.ipam.add_l2vpntermination %}
L2VPN Termination
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index acd52178d..301af8d18 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
+ 'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'ABC123',
'asset_tag': 'Foo1',
},
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[4].pk,
'module_type': module_types[1].pk,
+ 'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'DEF456',
'asset_tag': 'Foo2',
},
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[5].pk,
'module_type': module_types[2].pk,
+ 'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'GHI789',
'asset_tag': 'Foo3',
},
@@ -1485,6 +1488,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
)
Interface.objects.bulk_create(interfaces)
+ vdcs = (
+ VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
+ VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
+ )
+ VirtualDeviceContext.objects.bulk_create(vdcs)
+
vlans = (
VLAN(name='VLAN 1', vid=1),
VLAN(name='VLAN 2', vid=2),
@@ -1533,6 +1542,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
},
{
'device': device.pk,
+ 'vdcs': [vdcs[0].pk],
'name': 'Interface 6',
'type': 'virtual',
'mode': InterfaceModeChoices.MODE_TAGGED,
@@ -1543,6 +1553,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
},
{
'device': device.pk,
+ 'vdcs': [vdcs[1].pk],
'name': 'Interface 7',
'type': InterfaceTypeChoices.TYPE_80211A,
'tx_power': 10,
@@ -1551,6 +1562,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
},
{
'device': device.pk,
+ 'vdcs': [vdcs[1].pk],
'name': 'Interface 8',
'type': InterfaceTypeChoices.TYPE_80211A,
'tx_power': 10,
@@ -1945,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase):
class ConnectedDeviceTest(APITestCase):
- def setUp(self):
-
- super().setUp()
-
+ @classmethod
+ def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
- self.device1 = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
+ devices = (
+ Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site),
+ Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site),
)
- self.device2 = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
+ Device.objects.bulk_create(devices)
+ interfaces = (
+ Interface(device=devices[0], name='eth0'),
+ Interface(device=devices[1], name='eth0'),
+ Interface(device=devices[0], name='eth1'), # Not connected
)
- self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
- self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
- self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
+ Interface.objects.bulk_create(interfaces)
- cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
+ cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
- url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
+ url_params = f'?peer_device=TestDevice1&peer_interface=eth0'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(response.data['name'], self.device2.name)
+ self.assertEqual(response.data['name'], 'TestDevice2')
- url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
+ url_params = f'?peer_device=TestDevice1&peer_interface=eth1'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
@@ -2057,6 +2069,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
cls.bulk_update_data = {
'domain': 'newdomain',
+ 'master': None
}
@@ -2162,3 +2175,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
'type': REDUNDANT,
},
]
+
+
+class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
+ model = VirtualDeviceContext
+ brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
+ bulk_update_data = {
+ 'status': 'planned',
+ }
+
+ @classmethod
+ def setUpTestData(cls):
+ site = Site.objects.create(name='Test Site', slug='test-site')
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
+ devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
+
+ devices = (
+ Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site),
+ )
+ Device.objects.bulk_create(devices)
+
+ vdcs = (
+ VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'),
+ VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'),
+ VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'),
+ VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'),
+ VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'),
+ VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'),
+ VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'),
+ )
+ VirtualDeviceContext.objects.bulk_create(vdcs)
+
+ cls.create_data = [
+ {
+ 'device': devices[0].pk,
+ 'status': 'active',
+ 'name': 'VDC 1',
+ 'identifier': 1,
+ },
+ {
+ 'device': devices[0].pk,
+ 'status': 'active',
+ 'name': 'VDC 2',
+ 'identifier': 2,
+ },
+ {
+ 'device': devices[1].pk,
+ 'status': 'active',
+ 'name': 'VDC 3',
+ 'identifier': 3,
+ },
+ ]
diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py
index cfbbbc63b..50a707bc6 100644
--- a/netbox/dcim/tests/test_cablepaths.py
+++ b/netbox/dcim/tests/test_cablepaths.py
@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
+ self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1
cable1.delete()
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index 1aaf861ef..6fb3feb11 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, max_weight=1000, 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, max_weight=2000, 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, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
Rack.objects.bulk_create(racks)
@@ -517,6 +517,18 @@ 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_max_weight(self):
+ params = {'max_weight': [1000, 2000]}
+ 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 +700,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),
- 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)
@@ -753,9 +765,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_full_depth(self):
- params = {'is_full_depth': 'true'}
+ params = {'is_full_depth': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'is_full_depth': 'false'}
+ params = {'is_full_depth': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_subdevice_role(self):
@@ -773,6 +785,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_has_front_image(self):
+ params = {'has_front_image': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'has_front_image': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_has_rear_image(self):
+ params = {'has_rear_image': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ params = {'has_rear_image': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_console_ports(self):
params = {'console_ports': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -827,6 +851,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()
@@ -843,9 +875,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)
@@ -931,6 +963,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()
@@ -1586,10 +1626,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
+ IPAddress(address='192.0.2.3/24', assigned_object=None),
+ IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
+ IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
+ IPAddress(address='2001:db8::3/64', assigned_object=None),
)
IPAddress.objects.bulk_create(ipaddresses)
- Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
- Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1])
+ Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
+ Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
@@ -1599,6 +1643,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']}
@@ -1631,6 +1678,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'device_type': [device_types[0].slug, device_types[1].slug]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_devicerole(self):
device_roles = DeviceRole.objects.all()[:2]
@@ -1716,6 +1765,20 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'has_primary_ip': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_primary_ip4(self):
+ addresses = IPAddress.objects.filter(address__family=4)
+ params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip4_id': [addresses[2].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+ def test_primary_ip6(self):
+ addresses = IPAddress.objects.filter(address__family=6)
+ params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'primary_ip6_id': [addresses[2].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1835,15 +1898,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
ModuleBay.objects.bulk_create(module_bays)
modules = (
- Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
- Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
- Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
- Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
- Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
- Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
- Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
- Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
- Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
+ Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
+ Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
+ Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
+ Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
+ Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
+ Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
+ Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
+ Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
+ Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
)
Module.objects.bulk_create(modules)
@@ -1871,6 +1934,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+ def test_status(self):
+ params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_serial(self):
params = {'serial': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1983,12 +2050,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2037,9 +2098,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_connected(self):
+ params = {'connected': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2144,12 +2217,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2198,9 +2265,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_connected(self):
+ params = {'connected': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2313,12 +2392,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'allocated_draw': [50, 100]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2367,9 +2440,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_connected(self):
+ params = {'connected': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2478,12 +2563,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2532,9 +2611,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+ def test_connected(self):
+ params = {'connected': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2616,6 +2707,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
VRF.objects.bulk_create(vrfs)
+ # Virtual Device Context Creation
+ vdcs = (
+ VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+ VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+ )
+ VirtualDeviceContext.objects.bulk_create(vdcs)
+
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
@@ -2728,6 +2826,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Interface.objects.bulk_create(interfaces)
+ interfaces[3].vdcs.set([vdcs[0], vdcs[1]])
+ interfaces[4].vdcs.set([vdcs[0], vdcs[1]])
+ interfaces[5].vdcs.set([vdcs[0]])
+ interfaces[6].vdcs.set([vdcs[0]])
+ interfaces[7].vdcs.set([vdcs[1]])
+
# Cables
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
@@ -2741,12 +2845,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_connected(self):
- params = {'connected': True}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
- params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
-
def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
@@ -2880,9 +2978,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'occupied': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_connected(self):
+ params = {'connected': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_kind(self):
@@ -2926,6 +3036,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_vdc(self):
+ params = {'vdc': ['VDC 1']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ devices = Device.objects.last()
+ vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
+ params = {'vdc_id': vdc.values_list('pk', flat=True)}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_vdc_identifier(self):
+ devices = Device.objects.last()
+ vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
+ params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all()
@@ -3091,9 +3216,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -3255,9 +3386,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_occupied(self):
+ params = {'occupied': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -4159,9 +4296,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
- params = {'cabled': 'true'}
+ params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- params = {'cabled': 'false'}
+ params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
@@ -4171,4 +4308,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
-# TODO: Connection filters
+class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
+ queryset = VirtualDeviceContext.objects.all()
+ filterset = VirtualDeviceContextFilterSet
+
+ @classmethod
+ def setUpTestData(cls):
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
+
+ tenants = (
+ Tenant(name='Tenant 1', slug='tenant-1'),
+ Tenant(name='Tenant 2', slug='tenant-2'),
+ Tenant(name='Tenant 3', slug='tenant-3'),
+ )
+ Tenant.objects.bulk_create(tenants)
+
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
+ device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+ devices = (
+ Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
+ Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
+ Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
+ )
+ Device.objects.bulk_create(devices)
+
+ vdcs = (
+ VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+ VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+ VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE),
+ VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
+ VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+ VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
+ )
+ VirtualDeviceContext.objects.bulk_create(vdcs)
+
+ interfaces = (
+ Interface(device=devices[0], name='Interface 1', type='virtual'),
+ Interface(device=devices[0], name='Interface 2', type='virtual'),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ interfaces[0].vdcs.set([vdcs[0]])
+ interfaces[1].vdcs.set([vdcs[1]])
+
+ addresses = (
+ IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
+ IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
+ )
+ IPAddress.objects.bulk_create(addresses)
+
+ vdcs[0].primary_ip4 = addresses[0]
+ vdcs[0].save()
+ vdcs[1].primary_ip4 = addresses[1]
+ vdcs[1].save()
+
+ def test_device(self):
+ params = {'device': ['Device 1', 'Device 2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+ def test_status(self):
+ params = {'status': ['active']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+ def test_device_id(self):
+ devices = Device.objects.filter(name__in=['Device 1', 'Device 2'])
+ params = {'device_id': [devices[0].pk, devices[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+ def test_has_primary_ip(self):
+ params = {'has_primary_ip': True}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'has_primary_ip': False}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py
index 53474314f..1cd75765a 100644
--- a/netbox/dcim/tests/test_forms.py
+++ b/netbox/dcim/tests/test_forms.py
@@ -1,6 +1,6 @@
from django.test import TestCase
-from dcim.choices import DeviceFaceChoices, DeviceStatusChoices
+from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
from dcim.forms import *
from dcim.models import *
from utilities.testing import create_test_device
@@ -129,10 +129,11 @@ class LabelTestCase(TestCase):
"""
interface_data = {
'device': self.device.pk,
- 'name_pattern': 'eth[0-9]',
- 'label_pattern': 'Interface[0-9]',
+ 'name': 'eth[0-9]',
+ 'label': 'Interface[0-9]',
+ 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
}
- form = DeviceComponentCreateForm(interface_data)
+ form = InterfaceCreateForm(interface_data)
self.assertTrue(form.is_valid())
@@ -142,10 +143,11 @@ class LabelTestCase(TestCase):
"""
bad_interface_data = {
'device': self.device.pk,
- 'name_pattern': 'eth[0-9]',
- 'label_pattern': 'Interface[0-1]',
+ 'name': 'eth[0-9]',
+ 'label': 'Interface[0-1]',
+ 'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
}
- form = DeviceComponentCreateForm(bad_interface_data)
+ form = InterfaceCreateForm(bad_interface_data)
self.assertFalse(form.is_valid())
- self.assertIn('label_pattern', form.errors)
+ self.assertIn('label', form.errors)
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index 0e02b0de5..e9a577648 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -73,7 +73,8 @@ class LocationTestCase(TestCase):
class RackTestCase(TestCase):
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
@@ -240,30 +241,31 @@ class RackTestCase(TestCase):
class DeviceTestCase(TestCase):
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
- self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.device_type = DeviceType.objects.create(
+ device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
- self.device_role = DeviceRole.objects.create(
+ device_role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
# Create DeviceType components
ConsolePortTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Console Port 1'
).save()
ConsoleServerPortTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
@@ -271,21 +273,21 @@ class DeviceTestCase(TestCase):
ppt.save()
PowerOutletTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Power Outlet 1',
power_port=ppt,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
InterfaceTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
).save()
rpt = RearPortTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
@@ -293,7 +295,7 @@ class DeviceTestCase(TestCase):
rpt.save()
FrontPortTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rpt,
@@ -301,12 +303,12 @@ class DeviceTestCase(TestCase):
).save()
ModuleBayTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Module Bay 1'
).save()
DeviceBayTemplate(
- device_type=self.device_type,
+ device_type=device_type,
name='Device Bay 1'
).save()
@@ -315,9 +317,9 @@ class DeviceTestCase(TestCase):
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
- site=self.site,
- device_type=self.device_type,
- device_role=self.device_role,
+ site=Site.objects.first(),
+ device_type=DeviceType.objects.first(),
+ device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
d.save()
@@ -381,10 +383,10 @@ class DeviceTestCase(TestCase):
def test_multiple_unnamed_devices(self):
device1 = Device(
- site=self.site,
- device_type=self.device_type,
- device_role=self.device_role,
- name=''
+ site=Site.objects.first(),
+ device_type=DeviceType.objects.first(),
+ device_role=DeviceRole.objects.first(),
+ name=None
)
device1.save()
@@ -392,19 +394,40 @@ 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=Site.objects.first(),
+ device_type=DeviceType.objects.first(),
+ device_role=DeviceRole.objects.first(),
+ 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):
device1 = Device(
- site=self.site,
- device_type=self.device_type,
- device_role=self.device_role,
+ site=Site.objects.first(),
+ device_type=DeviceType.objects.first(),
+ device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
device1.save()
@@ -438,7 +461,8 @@ class DeviceTestCase(TestCase):
class CableTestCase(TestCase):
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -448,72 +472,76 @@ class CableTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
- self.device1 = Device.objects.create(
+ device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
- self.device2 = Device.objects.create(
+ device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
- self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
- self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
- self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
- self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
- self.cable.save()
+ interface1 = Interface.objects.create(device=device1, name='eth0')
+ interface2 = Interface.objects.create(device=device2, name='eth0')
+ interface3 = Interface.objects.create(device=device2, name='eth1')
+ Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
- self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
- self.patch_pannel = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
+ power_port1 = PowerPort.objects.create(device=device2, name='psu1')
+ patch_pannel = Device.objects.create(
+ device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site
)
- self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
- self.front_port1 = FrontPort.objects.create(
- device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
+ rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c')
+ front_port1 = FrontPort.objects.create(
+ device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1
)
- self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
- self.front_port2 = FrontPort.objects.create(
- device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
+ rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2)
+ front_port2 = FrontPort.objects.create(
+ device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1
)
- self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
- self.front_port3 = FrontPort.objects.create(
- device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
+ rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3)
+ front_port3 = FrontPort.objects.create(
+ device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
)
- self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
- self.front_port4 = FrontPort.objects.create(
- device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
+ rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
+ front_port4 = FrontPort.objects.create(
+ device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
)
- self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
- provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider)
- self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
- self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
- self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
- self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A')
- self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z')
- self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
+ circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+ circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
+ circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
+ circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
+ circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
+ circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
def test_cable_creation(self):
"""
When a new Cable is created, it must be cached on either termination point.
"""
- self.interface1.refresh_from_db()
- self.interface2.refresh_from_db()
- self.assertEqual(self.interface1.cable, self.cable)
- self.assertEqual(self.interface2.cable, self.cable)
- self.assertEqual(self.interface1.cable_end, 'A')
- self.assertEqual(self.interface2.cable_end, 'B')
- self.assertEqual(self.interface1.link_peers, [self.interface2])
- self.assertEqual(self.interface2.link_peers, [self.interface1])
+ interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+ interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+ cable = Cable.objects.first()
+ self.assertEqual(interface1.cable, cable)
+ self.assertEqual(interface2.cable, cable)
+ self.assertEqual(interface1.cable_end, 'A')
+ self.assertEqual(interface2.cable_end, 'B')
+ self.assertEqual(interface1.link_peers, [interface2])
+ self.assertEqual(interface2.link_peers, [interface1])
def test_cable_deletion(self):
"""
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
should still return the PK of the string even after being nullified.
"""
- self.cable.delete()
- self.assertIsNone(self.cable.pk)
- self.assertNotEqual(str(self.cable), '#None')
- interface1 = Interface.objects.get(pk=self.interface1.pk)
+ interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+ interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+ cable = Cable.objects.first()
+
+ cable.delete()
+ self.assertIsNone(cable.pk)
+ self.assertNotEqual(str(cable), '#None')
+ interface1 = Interface.objects.get(pk=interface1.pk)
self.assertIsNone(interface1.cable)
self.assertListEqual(interface1.link_peers, [])
- interface2 = Interface.objects.get(pk=self.interface2.pk)
+ interface2 = Interface.objects.get(pk=interface2.pk)
self.assertIsNone(interface2.cable)
self.assertListEqual(interface2.link_peers, [])
@@ -521,7 +549,10 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
"""
- cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
+ interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+ powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
+
+ cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -529,7 +560,11 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable are of the same type.
"""
- cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
+ interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+ frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
+ rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
+
+ cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -537,8 +572,11 @@ class CableTestCase(TestCase):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
+ interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+ powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
+
# An interface cannot be connected to a power port, for example
- cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
+ cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -546,7 +584,10 @@ class CableTestCase(TestCase):
"""
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
"""
- cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
+ interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
+ circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
+
+ cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
with self.assertRaises(ValidationError):
cable.clean()
@@ -554,8 +595,11 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a virtual interface
"""
- virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
- cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
+ device1 = Device.objects.get(name='TestDevice1')
+ interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+
+ virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
+ cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
with self.assertRaises(ValidationError):
cable.clean()
@@ -563,7 +607,63 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a wireless interface
"""
- wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
- cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
+ device1 = Device.objects.get(name='TestDevice1')
+ interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+
+ wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
+ cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
with self.assertRaises(ValidationError):
cable.clean()
+
+
+class VirtualDeviceContextTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+
+ site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ )
+ devicerole = DeviceRole.objects.create(
+ name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+ )
+ Device.objects.create(
+ device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
+ )
+
+ def test_vdc_and_interface_creation(self):
+ device = Device.objects.first()
+
+ vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
+ vdc.full_clean()
+ vdc.save()
+
+ interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
+ interface.full_clean()
+ interface.save()
+
+ interface.vdcs.set([vdc])
+
+ def test_vdc_duplicate_name(self):
+ device = Device.objects.first()
+
+ vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
+ vdc1.full_clean()
+ vdc1.save()
+
+ vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
+ with self.assertRaises(ValidationError):
+ vdc2.full_clean()
+
+ def test_vdc_duplicate_identifier(self):
+ device = Device.objects.first()
+
+ vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
+ vdc1.full_clean()
+ vdc1.save()
+
+ vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
+ with self.assertRaises(ValidationError):
+ vdc2.full_clean()
diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py
index 5c42b3ab4..8edaf0a90 100644
--- a/netbox/dcim/tests/test_natural_ordering.py
+++ b/netbox/dcim/tests/test_natural_ordering.py
@@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
class NaturalOrderingTestCase(TestCase):
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
- self.device = Device.objects.create(
+ Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
def test_interface_ordering_numeric(self):
-
+ device = Device.objects.first()
INTERFACES = [
'0',
'0.0',
@@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
- iface = Interface(device=self.device, name=name)
+ iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
- list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
+ list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_linux(self):
-
+ device = Device.objects.first()
INTERFACES = [
'eth0',
'eth0.1',
@@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
- iface = Interface(device=self.device, name=name)
+ iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
- list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
+ list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_junos(self):
-
+ device = Device.objects.first()
INTERFACES = [
'xe-0/0/0',
'xe-0/0/1',
@@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
- iface = Interface(device=self.device, name=name)
+ iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
- list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
+ list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_ios(self):
-
+ device = Device.objects.first()
INTERFACES = [
'GigabitEthernet0/1',
'GigabitEthernet0/2',
@@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
- iface = Interface(device=self.device, name=name)
+ iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
- list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
+ list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index a25267166..4b39dc27e 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1,6 +1,10 @@
from decimal import Decimal
+try:
+ from zoneinfo import ZoneInfo
+except ImportError:
+ # Python 3.8
+ from backports.zoneinfo import ZoneInfo
-import pytz
import yaml
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
@@ -12,8 +16,8 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
-from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
+from utilities.choices import ImportFormatChoices
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -50,6 +54,13 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Region 6,region-6,Sixth region",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{regions[0].pk},Region 7,Fourth region7",
+ f"{regions[1].pk},Region 8,Fifth region8",
+ f"{regions[2].pk},Region 0,Sixth region9",
+ )
+
cls.bulk_edit_data = {
'description': 'New description',
}
@@ -87,6 +98,13 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Site Group 6,site-group-6,Sixth site group",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{sitegroups[0].pk},Site Group 7,Fourth site group7",
+ f"{sitegroups[1].pk},Site Group 8,Fifth site group8",
+ f"{sitegroups[2].pk},Site Group 0,Sixth site group9",
+ )
+
cls.bulk_edit_data = {
'description': 'New description',
}
@@ -139,7 +157,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tenant': None,
'facility': 'Facility X',
'asns': [asns[6].pk, asns[7].pk],
- 'time_zone': pytz.UTC,
+ 'time_zone': ZoneInfo('UTC'),
'description': 'Site description',
'physical_address': '742 Evergreen Terrace, Springfield, USA',
'shipping_address': '742 Evergreen Terrace, Springfield, USA',
@@ -156,12 +174,19 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 6,site-6,staging",
)
+ cls.csv_update_data = (
+ "id,name,status",
+ f"{sites[0].pk},Site 7,staging",
+ f"{sites[1].pk},Site 8,planned",
+ f"{sites[2].pk},Site 9,active",
+ )
+
cls.bulk_edit_data = {
'status': SiteStatusChoices.STATUS_PLANNED,
'region': regions[1].pk,
'group': groups[1].pk,
'tenant': None,
- 'time_zone': pytz.timezone('US/Eastern'),
+ 'time_zone': ZoneInfo('US/Eastern'),
'description': 'New description',
}
@@ -202,6 +227,13 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Site 1,Tenant 1,Location 6,location-6,planned,Sixth location",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{locations[0].pk},Location 7,Fourth location7",
+ f"{locations[1].pk},Location 8,Fifth location8",
+ f"{locations[2].pk},Location 0,Sixth location9",
+ )
+
cls.bulk_edit_data = {
'description': 'New description',
}
@@ -213,11 +245,12 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- RackRole.objects.bulk_create([
+ rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1'),
RackRole(name='Rack Role 2', slug='rack-role-2'),
RackRole(name='Rack Role 3', slug='rack-role-3'),
- ])
+ )
+ RackRole.objects.bulk_create(rack_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -236,6 +269,13 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Rack Role 6,rack-role-6,0000ff",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{rack_roles[0].pk},Rack Role 7,New description7",
+ f"{rack_roles[1].pk},Rack Role 8,New description8",
+ f"{rack_roles[2].pk},Rack Role 9,New description9",
+ )
+
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
@@ -259,11 +299,12 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
rack = Rack(name='Rack 1', site=site, location=location)
rack.save()
- RackReservation.objects.bulk_create([
+ rack_reservations = (
RackReservation(rack=rack, user=user2, units=[1, 2, 3], description='Reservation 1'),
RackReservation(rack=rack, user=user2, units=[4, 5, 6], description='Reservation 2'),
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
- ])
+ )
+ RackReservation.objects.bulk_create(rack_reservations)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -283,6 +324,13 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3',
)
+ cls.csv_update_data = (
+ 'id,description',
+ f'{rack_reservations[0].pk},New description1',
+ f'{rack_reservations[1].pk},New description2',
+ f'{rack_reservations[2].pk},New description3',
+ )
+
cls.bulk_edit_data = {
'user': user3.pk,
'tenant': None,
@@ -315,11 +363,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
RackRole.objects.bulk_create(rackroles)
- Rack.objects.bulk_create((
+ racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[0]),
Rack(name='Rack 3', site=sites[0]),
- ))
+ )
+ Rack.objects.bulk_create(racks)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -340,15 +389,25 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
+ 'weight': 100,
+ 'max_weight': 2000,
+ 'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
- "site,location,name,status,width,u_height",
- "Site 1,,Rack 4,active,19,42",
- "Site 1,Location 1,Rack 5,active,19,42",
- "Site 2,Location 2,Rack 6,active,19,42",
+ "site,location,name,status,width,u_height,weight,max_weight,weight_unit",
+ "Site 1,,Rack 4,active,19,42,100,2000,kg",
+ "Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
+ "Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
+ )
+
+ cls.csv_update_data = (
+ "id,name,status",
+ f"{racks[0].pk},Rack 7,{RackStatusChoices.STATUS_DEPRECATED}",
+ f"{racks[1].pk},Rack 8,{RackStatusChoices.STATUS_DEPRECATED}",
+ f"{racks[2].pk},Rack 9,{RackStatusChoices.STATUS_DEPRECATED}",
)
cls.bulk_edit_data = {
@@ -365,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 30,
'outer_depth': 30,
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
+ 'weight': 200,
+ 'max_weight': 4000,
+ 'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'New comments',
}
@@ -383,11 +445,12 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- Manufacturer.objects.bulk_create([
+ manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
- ])
+ )
+ Manufacturer.objects.bulk_create(manufacturers)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -405,6 +468,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Manufacturer 6,manufacturer-6,Sixth manufacturer",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{manufacturers[0].pk},Manufacturer 7,Fourth manufacturer7",
+ f"{manufacturers[1].pk},Manufacturer 8,Fifth manufacturer8",
+ f"{manufacturers[2].pk},Manufacturer 9,Sixth manufacturer9",
+ )
+
cls.bulk_edit_data = {
'description': 'New description',
}
@@ -1082,31 +1152,28 @@ front-ports:
class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsolePortTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ConsolePortTemplate.objects.bulk_create((
- ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
- ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
- ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Console Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Console Port Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Console Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
@@ -1117,31 +1184,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsoleServerPortTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ConsoleServerPortTemplate.objects.bulk_create((
- ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
- ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
- ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Console Server Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Console Server Port Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Console Server Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
@@ -1152,24 +1216,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerPortTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
PowerPortTemplate.objects.bulk_create((
- PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
- PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
- PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Power Port Template X',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
@@ -1177,8 +1238,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Power Port Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Power Port Template [4-6]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
@@ -1193,6 +1254,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerOutletTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1220,7 +1282,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
cls.bulk_create_data = {
'device_type': devicetype.pk,
- 'name_pattern': 'Power Outlet Template [4-6]',
+ 'name': 'Power Outlet Template [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[0].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -1234,34 +1296,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = InterfaceTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
InterfaceTemplate.objects.bulk_create((
- InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
- InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
- InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 1'),
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 2'),
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Interface Template X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Interface Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Interface Template [4-6]',
# Test that a label can be applied to each generated interface templates
- 'label_pattern': 'Interface Template Label [3-5]',
+ 'label': 'Interface Template Label [3-5]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
@@ -1274,6 +1333,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = FrontPortTemplate
+ validation_excluded_fields = ('name', 'label', 'rear_port')
@classmethod
def setUpTestData(cls):
@@ -1306,11 +1366,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
cls.bulk_create_data = {
'device_type': devicetype.pk,
- 'name_pattern': 'Front Port [4-6]',
+ 'name': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port_set': [
- '{}:1'.format(rp.pk) for rp in rearports[3:6]
- ],
+ 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
}
cls.bulk_edit_data = {
@@ -1320,32 +1378,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = RearPortTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
RearPortTemplate.objects.bulk_create((
- RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
- RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
- RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
+ RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
+ RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
+ RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Rear Port Template X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Rear Port Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Rear Port Template [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
@@ -1357,30 +1412,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ModuleBayTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ModuleBayTemplate.objects.bulk_create((
- ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'),
- ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'),
- ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'),
+ ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
+ ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
+ ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Module Bay Template X',
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Module Bay Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Module Bay Template [4-6]',
}
cls.bulk_edit_data = {
@@ -1390,30 +1442,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- devicetypes = (
- DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
- DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
DeviceBayTemplate.objects.bulk_create((
- DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
- DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
- DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
))
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Device Bay Template X',
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Device Bay Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Device Bay Template [4-6]',
}
cls.bulk_edit_data = {
@@ -1423,6 +1472,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = InventoryItemTemplate
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1431,30 +1481,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
-
- devicetypes = (
- DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
- DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
- )
- DeviceType.objects.bulk_create(devicetypes)
+ devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1')
inventory_item_templates = (
- InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]),
- InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]),
- InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]),
+ InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]),
+ InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]),
+ InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]),
)
for item in inventory_item_templates:
item.save()
cls.form_data = {
- 'device_type': devicetypes[1].pk,
+ 'device_type': devicetype.pk,
'name': 'Inventory Item Template X',
'manufacturer': manufacturers[1].pk,
}
cls.bulk_create_data = {
- 'device_type': devicetypes[1].pk,
- 'name_pattern': 'Inventory Item Template [4-6]',
+ 'device_type': devicetype.pk,
+ 'name': 'Inventory Item Template [4-6]',
'manufacturer': manufacturers[1].pk,
}
@@ -1469,11 +1514,12 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- DeviceRole.objects.bulk_create([
+ device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
- ])
+ )
+ DeviceRole.objects.bulk_create(device_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -1493,6 +1539,13 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Device Role 6,device-role-6,0000ff",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{device_roles[0].pk},Device Role 7,New description7",
+ f"{device_roles[1].pk},Device Role 8,New description8",
+ f"{device_roles[2].pk},Device Role 9,New description9",
+ )
+
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
@@ -1507,11 +1560,12 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
- Platform.objects.bulk_create([
+ platforms = (
Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturer),
Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturer),
Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer),
- ])
+ )
+ Platform.objects.bulk_create(platforms)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -1532,6 +1586,13 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Platform 6,platform-6,Sixth platform",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{platforms[0].pk},Platform 7,Fourth platform7",
+ f"{platforms[1].pk},Platform 8,Fifth platform8",
+ f"{platforms[2].pk},Platform 9,Sixth platform9",
+ )
+
cls.bulk_edit_data = {
'napalm_driver': 'ios',
'description': 'New description',
@@ -1579,11 +1640,12 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Platform.objects.bulk_create(platforms)
- Device.objects.bulk_create([
+ devices = (
Device(name='Device 1', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
Device(name='Device 2', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
- ])
+ )
+ Device.objects.bulk_create(devices)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -1620,6 +1682,13 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
)
+ cls.csv_update_data = (
+ "id,status",
+ f"{devices[0].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
+ f"{devices[1].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
+ f"{devices[2].pk},{DeviceStatusChoices.STATUS_DECOMMISSIONING}",
+ )
+
cls.bulk_edit_data = {
'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk,
@@ -1803,10 +1872,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)
@@ -1820,22 +1891,31 @@ 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,
+ 'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'A',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'module_type': module_types[3].pk,
+ 'status': ModuleStatusChoices.STATUS_PLANNED,
}
cls.csv_data = (
- "device,module_bay,module_type,serial,asset_tag",
- "Device 2,Module Bay 1,Module Type 1,A,A",
- "Device 2,Module Bay 2,Module Type 2,B,B",
- "Device 2,Module Bay 3,Module Type 3,C,C",
+ "device,module_bay,module_type,status,serial,asset_tag",
+ "Device 2,Module Bay 1,Module Type 1,active,A,A",
+ "Device 2,Module Bay 2,Module Type 2,planned,B,B",
+ "Device 2,Module Bay 3,Module Type 3,failed,C,C",
+ )
+
+ cls.csv_update_data = (
+ "id,status,serial",
+ f"{modules[0].pk},offline,Serial 2",
+ f"{modules[1].pk},offline,Serial 3",
+ f"{modules[2].pk},offline,Serial 1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -1871,6 +1951,54 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_module_bulk_replication(self):
+ self.add_permissions('dcim.add_module')
+
+ # Add 5 InterfaceTemplates to a ModuleType
+ module_type = ModuleType.objects.first()
+ interface_templates = [
+ InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
+ for i in range(1, 6)
+ ]
+ InterfaceTemplate.objects.bulk_create(interface_templates)
+
+ # Create a module *without* replicating components
+ device = Device.objects.get(name='Device 2')
+ module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
+ csv_data = [
+ "device,module_bay,module_type,status,replicate_components",
+ f"{device.name},{module_bay.name},{module_type.model},active,false"
+ ]
+ request = {
+ 'path': self._get_url('import'),
+ 'data': {
+ 'data': '\n'.join(csv_data),
+ 'format': ImportFormatChoices.CSV,
+ }
+ }
+
+ initial_count = Module.objects.count()
+ self.assertHttpStatus(self.client.post(**request), 200)
+ self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
+ self.assertEqual(Interface.objects.filter(device=device).count(), 0)
+
+ # Create a second module (in the next bay) with replicated components
+ module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
+ csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
+ request = {
+ 'path': self._get_url('import'),
+ 'data': {
+ 'data': '\n'.join(csv_data),
+ 'format': ImportFormatChoices.CSV,
+ }
+ }
+
+ initial_count = Module.objects.count()
+ self.assertHttpStatus(self.client.post(**request), 200)
+ self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
+ self.assertEqual(Interface.objects.filter(device=device).count(), 5)
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_adoption(self):
self.add_permissions('dcim.add_module')
@@ -1892,7 +2020,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
@@ -1909,19 +2036,65 @@ class ModuleTestCase(
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_module_bulk_adoption(self):
+ self.add_permissions('dcim.add_module')
+
+ interface_name = "Interface-1"
+
+ # Add an interface to the ModuleType
+ module_type = ModuleType.objects.first()
+ InterfaceTemplate(module_type=module_type, name=interface_name).save()
+
+ form_data = self.form_data.copy()
+ device = Device.objects.get(pk=form_data['device'])
+
+ # Create an interface to be adopted
+ interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
+ interface.save()
+
+ # Ensure that interface is created with no module
+ self.assertIsNone(interface.module)
+
+ # Create a module with adopted components
+ module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
+ csv_data = [
+ "device,module_bay,module_type,status,replicate_components,adopt_components",
+ f"{device.name},{module_bay.name},{module_type.model},active,false,true"
+ ]
+ request = {
+ 'path': self._get_url('import'),
+ 'data': {
+ 'data': '\n'.join(csv_data),
+ 'format': ImportFormatChoices.CSV,
+ }
+ }
+
+ initial_count = self._get_queryset().count()
+ self.assertHttpStatus(self.client.post(**request), 200)
+ self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
+
+ # Re-retrieve interface to get new module id
+ interface.refresh_from_db()
+
+ # Check that the Interface now has a module
+ self.assertIsNotNone(interface.module)
+
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
- ConsolePort.objects.bulk_create([
+ console_ports = (
ConsolePort(device=device, name='Console Port 1'),
ConsolePort(device=device, name='Console Port 2'),
ConsolePort(device=device, name='Console Port 3'),
- ])
+ )
+ ConsolePort.objects.bulk_create(console_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -1935,9 +2108,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Console Port [4-6]',
+ 'name': 'Console Port [4-6]',
# Test that a label can be applied to each generated console ports
- 'label_pattern': 'Serial[3-5]',
+ 'label': 'Serial[3-5]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': sorted([t.pk for t in tags]),
@@ -1955,6 +2128,13 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Port 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{console_ports[0].pk},Console Port 7,New description7",
+ f"{console_ports[1].pk},Console Port 8,New description8",
+ f"{console_ports[2].pk},Console Port 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleport = ConsolePort.objects.first()
@@ -1970,16 +2150,18 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
- ConsoleServerPort.objects.bulk_create([
+ console_server_ports = (
ConsoleServerPort(device=device, name='Console Server Port 1'),
ConsoleServerPort(device=device, name='Console Server Port 2'),
ConsoleServerPort(device=device, name='Console Server Port 3'),
- ])
+ )
+ ConsoleServerPort.objects.bulk_create(console_server_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -1993,7 +2175,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Console Server Port [4-6]',
+ 'name': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': [t.pk for t in tags],
@@ -2011,6 +2193,13 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Console Server Port 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{console_server_ports[0].pk},Console Server Port 7,New description 7",
+ f"{console_server_ports[1].pk},Console Server Port 8,New description 8",
+ f"{console_server_ports[2].pk},Console Server Port 9,New description 9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
consoleserverport = ConsoleServerPort.objects.first()
@@ -2026,16 +2215,18 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
- PowerPort.objects.bulk_create([
+ power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
PowerPort(device=device, name='Power Port 3'),
- ])
+ )
+ PowerPort.objects.bulk_create(power_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2051,7 +2242,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Power Port [4-6]]',
+ 'name': 'Power Port [4-6]]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
@@ -2073,6 +2264,13 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Port 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{power_ports[0].pk},Power Port 7,New description7",
+ f"{power_ports[1].pk},Power Port 8,New description8",
+ f"{power_ports[2].pk},Power Port 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
powerport = PowerPort.objects.first()
@@ -2088,6 +2286,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2099,11 +2298,12 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
PowerPort.objects.bulk_create(powerports)
- PowerOutlet.objects.bulk_create([
+ power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1', power_port=powerports[0]),
PowerOutlet(device=device, name='Power Outlet 2', power_port=powerports[0]),
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
- ])
+ )
+ PowerOutlet.objects.bulk_create(power_outlets)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2119,7 +2319,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Power Outlet [4-6]',
+ 'name': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -2141,6 +2341,13 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Power Outlet 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{power_outlets[0].pk},Power Outlet 7,New description7",
+ f"{power_outlets[1].pk},Power Outlet 8,New description8",
+ f"{power_outlets[2].pk},Power Outlet 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
poweroutlet = PowerOutlet.objects.first()
@@ -2153,6 +2360,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2217,7 +2425,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Interface [4-6]',
+ 'name': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'bridge': interfaces[4].pk,
@@ -2266,6 +2474,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{interfaces[0].pk},Interface 7,New description7",
+ f"{interfaces[1].pk},Interface 8,New description8",
+ f"{interfaces[2].pk},Interface 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
interface1, interface2 = Interface.objects.all()[:2]
@@ -2277,6 +2492,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort
+ validation_excluded_fields = ('name', 'label', 'rear_port')
@classmethod
def setUpTestData(cls):
@@ -2292,11 +2508,12 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
RearPort.objects.bulk_create(rearports)
- FrontPort.objects.bulk_create([
+ front_ports = (
FrontPort(device=device, name='Front Port 1', rear_port=rearports[0]),
FrontPort(device=device, name='Front Port 2', rear_port=rearports[1]),
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
- ])
+ )
+ FrontPort.objects.bulk_create(front_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2312,11 +2529,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Front Port [4-6]',
+ 'name': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port_set': [
- '{}:1'.format(rp.pk) for rp in rearports[3:6]
- ],
+ 'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
'description': 'New description',
'tags': [t.pk for t in tags],
}
@@ -2333,6 +2548,13 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Front Port 6,8p8c,Rear Port 6,1",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{front_ports[0].pk},Front Port 7,New description7",
+ f"{front_ports[1].pk},Front Port 8,New description8",
+ f"{front_ports[2].pk},Front Port 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
frontport = FrontPort.objects.first()
@@ -2348,16 +2570,18 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
- RearPort.objects.bulk_create([
+ rear_ports = (
RearPort(device=device, name='Rear Port 1'),
RearPort(device=device, name='Rear Port 2'),
RearPort(device=device, name='Rear Port 3'),
- ])
+ )
+ RearPort.objects.bulk_create(rear_ports)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2372,7 +2596,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Rear Port [4-6]',
+ 'name': 'Rear Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
@@ -2391,6 +2615,13 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Rear Port 6,8p8c,1",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{rear_ports[0].pk},Rear Port 7,New description7",
+ f"{rear_ports[1].pk},Rear Port 8,New description8",
+ f"{rear_ports[2].pk},Rear Port 9,New description9",
+ )
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_trace(self):
rearport = RearPort.objects.first()
@@ -2406,16 +2637,18 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ModuleBay
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
device = create_test_device('Device 1')
- ModuleBay.objects.bulk_create([
+ module_bays = (
ModuleBay(device=device, name='Module Bay 1'),
ModuleBay(device=device, name='Module Bay 2'),
ModuleBay(device=device, name='Module Bay 3'),
- ])
+ )
+ ModuleBay.objects.bulk_create(module_bays)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2428,7 +2661,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Module Bay [4-6]',
+ 'name': 'Module Bay [4-6]',
'description': 'A module bay',
'tags': [t.pk for t in tags],
}
@@ -2444,9 +2677,17 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Module Bay 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{module_bays[0].pk},Module Bay 7,New description7",
+ f"{module_bays[1].pk},Module Bay 8,New description8",
+ f"{module_bays[2].pk},Module Bay 9,New description9",
+ )
+
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2455,11 +2696,12 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
# Update the DeviceType subdevice role to allow adding DeviceBays
DeviceType.objects.update(subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
- DeviceBay.objects.bulk_create([
+ device_bays = (
DeviceBay(device=device, name='Device Bay 1'),
DeviceBay(device=device, name='Device Bay 2'),
DeviceBay(device=device, name='Device Bay 3'),
- ])
+ )
+ DeviceBay.objects.bulk_create(device_bays)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2472,7 +2714,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Device Bay [4-6]',
+ 'name': 'Device Bay [4-6]',
'description': 'A device bay',
'tags': [t.pk for t in tags],
}
@@ -2488,9 +2730,17 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Device Bay 6",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{device_bays[0].pk},Device Bay 7,New description7",
+ f"{device_bays[1].pk},Device Bay 8,New description8",
+ f"{device_bays[2].pk},Device Bay 9,New description9",
+ )
+
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem
+ validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2503,9 +2753,9 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
InventoryItemRole.objects.bulk_create(roles)
- InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
- InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
- InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
+ inventory_item1 = InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
+ inventory_item2 = InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
+ inventory_item3 = InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2525,7 +2775,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
- 'name_pattern': 'Inventory Item [4-6]',
+ 'name': 'Inventory Item [4-6]',
'role': roles[1].pk,
'manufacturer': manufacturer.pk,
'parent': None,
@@ -2549,6 +2799,13 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
"Device 1,Inventory Item 6,Inventory Item 3",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{inventory_item1.pk},Inventory Item 7,New description7",
+ f"{inventory_item2.pk},Inventory Item 8,New description8",
+ f"{inventory_item3.pk},Inventory Item 9,New description9",
+ )
+
class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = InventoryItemRole
@@ -2556,11 +2813,12 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@classmethod
def setUpTestData(cls):
- InventoryItemRole.objects.bulk_create([
+ inventory_item_roles = (
InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'),
- ])
+ )
+ InventoryItemRole.objects.bulk_create(inventory_item_roles)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2579,6 +2837,13 @@ class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
"Inventory Item Role 6,inventory-item-role-6,0000ff",
)
+ cls.csv_update_data = (
+ "id,name,description",
+ f"{inventory_item_roles[0].pk},Inventory Item Role 7,New description7",
+ f"{inventory_item_roles[1].pk},Inventory Item Role 8,New description8",
+ f"{inventory_item_roles[2].pk},Inventory Item Role 9,New description9",
+ )
+
cls.bulk_edit_data = {
'color': '00ff00',
'description': 'New description',
@@ -2631,9 +2896,12 @@ class CableTestCase(
)
Interface.objects.bulk_create(interfaces)
- Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save()
- Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save()
- Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save()
+ cable1 = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6)
+ cable1.save()
+ cable2 = Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6)
+ cable2.save()
+ cable3 = Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6)
+ cable3.save()
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2659,6 +2927,13 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
)
+ cls.csv_update_data = (
+ "id,label,color",
+ f"{cable1.pk},New label7,00ff00",
+ f"{cable2.pk},New label8,00ff00",
+ f"{cable3.pk},New label9,00ff00",
+ )
+
cls.bulk_edit_data = {
'type': CableTypeChoices.TYPE_CAT5E,
'status': LinkStatusChoices.STATUS_CONNECTED,
@@ -2742,6 +3017,13 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"VC6,Domain 6,Device 12",
)
+ cls.csv_update_data = (
+ "id,name,domain",
+ f"{vc1.pk},VC7,Domain 7",
+ f"{vc2.pk},VC8,Domain 8",
+ f"{vc3.pk},VC9,Domain 9",
+ )
+
cls.bulk_edit_data = {
'domain': 'domain-x',
}
@@ -2766,11 +3048,12 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
for location in locations:
location.save()
- PowerPanel.objects.bulk_create((
+ power_panels = (
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'),
PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'),
- ))
+ )
+ PowerPanel.objects.bulk_create(power_panels)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2788,6 +3071,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 1,Location 1,Power Panel 6",
)
+ cls.csv_update_data = (
+ "id,name",
+ f"{power_panels[0].pk},Power Panel 7",
+ f"{power_panels[1].pk},Power Panel 8",
+ f"{power_panels[2].pk},Power Panel 9",
+ )
+
cls.bulk_edit_data = {
'site': sites[1].pk,
'location': locations[1].pk,
@@ -2814,11 +3104,12 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Rack.objects.bulk_create(racks)
- PowerFeed.objects.bulk_create((
+ power_feeds = (
PowerFeed(name='Power Feed 1', power_panel=powerpanels[0], rack=racks[0]),
PowerFeed(name='Power Feed 2', power_panel=powerpanels[0], rack=racks[0]),
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
- ))
+ )
+ PowerFeed.objects.bulk_create(power_feeds)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -2844,6 +3135,13 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
"Site 1,Power Panel 1,Power Feed 6,active,primary,ac,single-phase,120,20,80",
)
+ cls.csv_update_data = (
+ "id,name,status",
+ f"{power_feeds[0].pk},Power Feed 7,{PowerFeedStatusChoices.STATUS_PLANNED}",
+ f"{power_feeds[1].pk},Power Feed 8,{PowerFeedStatusChoices.STATUS_PLANNED}",
+ f"{power_feeds[2].pk},Power Feed 9,{PowerFeedStatusChoices.STATUS_PLANNED}",
+ )
+
cls.bulk_edit_data = {
'power_panel': powerpanels[1].pk,
'rack': racks[1].pk,
@@ -2879,3 +3177,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
self.assertHttpStatus(response, 200)
+
+
+class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+ model = VirtualDeviceContext
+
+ @classmethod
+ def setUpTestData(cls):
+ devices = [create_test_device(name='Device 1')]
+
+ vdcs = (
+ VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'),
+ VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'),
+ VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'),
+ )
+ VirtualDeviceContext.objects.bulk_create(vdcs)
+
+ tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+ cls.form_data = {
+ 'device': devices[0].pk,
+ 'status': 'active',
+ 'name': 'VDC 4',
+ 'identifier': 4,
+ 'primary_ip4': None,
+ 'primary_ip6': None,
+ 'tags': [t.pk for t in tags],
+ }
+
+ cls.csv_data = (
+ "device,status,name,identifier",
+ "Device 1,active,VDC 5,5",
+ "Device 1,active,VDC 6,6",
+ "Device 1,active,VDC 7,7",
+ )
+
+ cls.csv_update_data = (
+ "id,status",
+ f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+ f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+ f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
+ )
+
+ cls.bulk_edit_data = {
+ 'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
+ }
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index c11a92a99..6772f96ad 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,15 @@ 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'))),
+
+ # Virtual Device Context
+ path('virtual-device-contexts/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'),
+ path('virtual-device-contexts/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'),
+ path('virtual-device-contexts/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'),
+ path('virtual-device-contexts/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'),
+ path('virtual-device-contexts/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'),
+ path('virtual-device-contexts//', include(get_model_urls('dcim', 'virtualdevicecontext'))),
# Modules
path('modules/', views.ModuleListView.as_view(), name='module_list'),
@@ -276,11 +197,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 +207,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 +218,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 +229,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 +240,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 +251,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 +262,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 +273,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 +283,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 +293,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 +303,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 +320,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 +333,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 +342,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/