mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-31 09:37:45 -06:00
Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b933a5aa | ||
|
|
07da3f6d33 | ||
|
|
0613e8e95c | ||
|
|
113c60a44a | ||
|
|
8a237561ef | ||
|
|
cc0fc03ec3 | ||
|
|
b955751349 | ||
|
|
d6c8d1581c | ||
|
|
e6642b5f5b | ||
|
|
a67236fc3c | ||
|
|
634681a72e | ||
|
|
031b7540b3 | ||
|
|
43909ee33f | ||
|
|
99467e8f66 | ||
|
|
0d08205ab1 | ||
|
|
c289dda649 | ||
|
|
169207058f | ||
|
|
e5c565cbf4 | ||
|
|
f0b9008529 | ||
|
|
8dfec7e2b2 | ||
|
|
c1cf037eaf | ||
|
|
3f4a65cc5c | ||
|
|
12beac4f1a | ||
|
|
ec245b968f | ||
|
|
f1d4011b40 | ||
|
|
4cdc30a7c5 | ||
|
|
8d39181842 | ||
|
|
c81869c795 | ||
|
|
929d4d2c95 | ||
|
|
d14e4ab52b | ||
|
|
8a4233aca1 | ||
|
|
5508e125ba | ||
|
|
69bf1472d2 | ||
|
|
b93735861d | ||
|
|
6939ae4a47 | ||
|
|
81fa4265da | ||
|
|
35be4f05ef | ||
|
|
2ef023a160 | ||
|
|
9d7192202d | ||
|
|
95a8415e2d | ||
|
|
e59ee3e01e | ||
|
|
92bdaa2120 | ||
|
|
fe3f21105c | ||
|
|
32264ac3e3 | ||
|
|
b34daeaacb | ||
|
|
d2c3a39ebb | ||
|
|
d10ac9b4a7 | ||
|
|
b21ed6a334 | ||
|
|
9d09916f6e | ||
|
|
28080e9b14 | ||
|
|
04fd45581d | ||
|
|
0a8eb7fcbe | ||
|
|
ac3fc25dfd | ||
|
|
82591ad8a1 | ||
|
|
6dddb6c9d2 | ||
|
|
290aae592d | ||
|
|
ff021a8e4e | ||
|
|
3a3d43911c | ||
|
|
c43c63a817 | ||
|
|
792b353f64 | ||
|
|
01ba4ce129 | ||
|
|
fc7d6e1387 | ||
|
|
080da68b6a | ||
|
|
7d413ea3c2 | ||
|
|
40763b58bd | ||
|
|
d52a6d3b10 | ||
|
|
6ac25eeb65 | ||
|
|
41eae1bc19 | ||
|
|
351aaf8397 | ||
|
|
5c27d29b08 | ||
|
|
e1bedb8350 | ||
|
|
dd5e20aa1a | ||
|
|
217a9edb4c | ||
|
|
ad95760ead | ||
|
|
57bf2a2f00 | ||
|
|
e5c38e0829 | ||
|
|
6b89da2233 | ||
|
|
092f2b06ab | ||
|
|
6900097e2d | ||
|
|
5000564430 | ||
|
|
95519b42a0 | ||
|
|
dfef89ab88 | ||
|
|
0603dd1be4 | ||
|
|
1203d761f4 | ||
|
|
d2c727c0a2 | ||
|
|
ac4b46b502 | ||
|
|
6e8ee9db89 | ||
|
|
94858ac13f | ||
|
|
b0f2de5bd7 | ||
|
|
60e98324c3 | ||
|
|
66b9cdf141 | ||
|
|
22e474ff96 | ||
|
|
b3fb393490 | ||
|
|
5b2f29480a | ||
|
|
809b049590 | ||
|
|
2a0a7d45aa | ||
|
|
7efbfabc0b | ||
|
|
d195f9c6ea | ||
|
|
de298224f1 | ||
|
|
3fd8e48fac | ||
|
|
ab9de43447 | ||
|
|
51ef4fb920 | ||
|
|
7983c2590e | ||
|
|
d77d45e795 | ||
|
|
a24864bc6d | ||
|
|
c671ac2f28 | ||
|
|
18a813aa39 | ||
|
|
14447befb9 | ||
|
|
06ed7ac8a5 | ||
|
|
72f01b3e89 | ||
|
|
2522056bd1 | ||
|
|
01c894e625 | ||
|
|
4286c1cde2 | ||
|
|
383285fb94 | ||
|
|
e23b246d46 | ||
|
|
a543bd469a | ||
|
|
d03859b27b | ||
|
|
bbb133019d | ||
|
|
285187542d | ||
|
|
4d13f4d252 | ||
|
|
e4a9cad756 | ||
|
|
b93b331d86 | ||
|
|
a46255ddda | ||
|
|
6093debb71 | ||
|
|
6dc560596d | ||
|
|
5cb1a6b790 | ||
|
|
ef460a38ed | ||
|
|
786f0cc7f3 | ||
|
|
ccc9e89e1a | ||
|
|
9e35cefaf2 | ||
|
|
1a00765b72 | ||
|
|
4dd229e73a | ||
|
|
db40119faa | ||
|
|
f65744faee | ||
|
|
1ad6d94dc3 | ||
|
|
b759d694ee | ||
|
|
3cb41bbe3a | ||
|
|
099aff5ebe | ||
|
|
f9ceaad284 | ||
|
|
e67624f042 | ||
|
|
27297c7556 | ||
|
|
685ac5f571 | ||
|
|
0ce2b1b779 | ||
|
|
04796a6ac6 | ||
|
|
a8a4bd7c21 | ||
|
|
a0e5e69283 | ||
|
|
df46198b91 | ||
|
|
b670a1e22c | ||
|
|
9b325f4b86 |
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -10,16 +10,25 @@ body:
|
||||
installation. If you're having trouble with installation or just looking for
|
||||
assistance with using NetBox, please visit our
|
||||
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment Type
|
||||
description: How are you running NetBox?
|
||||
options:
|
||||
- Self-hosted
|
||||
- NetBox Cloud
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.2
|
||||
placeholder: v3.6.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Python version
|
||||
label: Python Version
|
||||
description: What version of Python are you currently running?
|
||||
options:
|
||||
- "3.8"
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.6.2
|
||||
placeholder: v3.6.8
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/translation.yaml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: 🌍 Translation
|
||||
description: Request support for a new language in the user interface
|
||||
labels: ["type: translation"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
**NOTE:** This template is used only for proposing the addition of *new* languages. Please do
|
||||
not use it to request changes to existing translations.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Language
|
||||
description: What is the name of the language in English?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: ISO 639-1 code
|
||||
description: >
|
||||
What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
|
||||
assigned to the language?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Volunteer
|
||||
description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Comments
|
||||
description: Any other notes you would like to share
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -31,15 +31,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
run: npm install -g yarn
|
||||
|
||||
- name: Setup Node.js with Yarn Caching
|
||||
uses: actions/setup-node@v2
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: yarn
|
||||
|
||||
2
.github/workflows/lock.yml
vendored
2
.github/workflows/lock.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
- uses: dessant/lock-threads@v4
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v6
|
||||
- uses: actions/stale@v8
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
@@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
|
||||
|
||||
## :bug: Reporting Bugs
|
||||
|
||||
:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
|
||||
|
||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
|
||||
|
||||
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
|
||||
|
||||
@@ -23,8 +23,9 @@ django-filter
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# Modified Preorder Tree Traversal (recursive nesting of objects)
|
||||
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
|
||||
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
|
||||
django-mptt
|
||||
django-mptt==0.14.0
|
||||
|
||||
# Context managers for PostgreSQL advisory locks
|
||||
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt
|
||||
@@ -52,7 +53,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
|
||||
django-taggit
|
||||
# TODO: Upgrade to v5.0 for NetBox v3.7 beta
|
||||
django-taggit<5.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
@@ -120,6 +122,10 @@ psycopg[binary,pool]
|
||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||
PyYAML
|
||||
|
||||
# Requests
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# Sentry SDK
|
||||
# https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md
|
||||
sentry-sdk
|
||||
|
||||
@@ -80,6 +80,14 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## DATA_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum size (in bytes) of an incoming HTTP request (i.e. `GET` or `POST` data). Requests which exceed this size will raise a `RequestDataTooBig` exception.
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
!!! tip "Dynamic Configuration Parameter"
|
||||
@@ -90,9 +98,9 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
|
||||
Default: `2621440` (2.5 MB).
|
||||
Default: `2621440` (2.5 MB)
|
||||
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
To run a custom script, a user must be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a custom script, a user must be assigned via permissions for `Extras > Script`, `Extras > ScriptModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_script` permission. This is achieved by assigning the user (or group) a permission on the Script object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
|
||||
## Running Reports
|
||||
|
||||
!!! note
|
||||
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
To run a report, a user must be assigned via permissions for `Extras > Report`, `Extras > ReportModule`, and `Core > ManagedFile` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
|
||||
|
||||
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.
|
||||
|
||||
!!! note
|
||||
NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
|
||||
|
||||
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
|
||||
|
||||
|
||||
!!! info
|
||||
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
|
||||
|
||||
@@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
|
||||
* Export templates
|
||||
|
||||
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
|
||||
|
||||
!!! note "Permissions"
|
||||
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
|
||||
|
||||
!!! note "Permissions"
|
||||
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
|
||||
|
||||
The following features support the use of synchronized data:
|
||||
|
||||
* [Configuration templates](../features/configuration-rendering.md)
|
||||
|
||||
@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
|
||||
|
||||
### Name
|
||||
|
||||
The inventory item's name. Must be unique to the parent device.
|
||||
The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
|
||||
|
||||
### Label
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
|
||||
]
|
||||
},
|
||||
{
|
||||
"attr": "tags",
|
||||
"attr": "tags.slug",
|
||||
"value": "exempt",
|
||||
"op": "contains"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,166 @@
|
||||
# NetBox v3.6
|
||||
|
||||
## v3.6.8 (2023-12-27)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
|
||||
* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
|
||||
* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
|
||||
* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
|
||||
* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
|
||||
* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
|
||||
* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
|
||||
* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
|
||||
* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
|
||||
* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
|
||||
* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
|
||||
* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
|
||||
* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
|
||||
* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
|
||||
* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
|
||||
|
||||
---
|
||||
|
||||
## v3.6.7 (2023-12-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
|
||||
* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
|
||||
* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
|
||||
* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
|
||||
* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
|
||||
* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
|
||||
* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
|
||||
* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
|
||||
* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
|
||||
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
|
||||
* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
|
||||
* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
|
||||
* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
|
||||
* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
|
||||
* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
|
||||
* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
|
||||
|
||||
---
|
||||
|
||||
## v3.6.6 (2023-11-29)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
|
||||
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
|
||||
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
|
||||
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
|
||||
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
|
||||
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
|
||||
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
|
||||
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
|
||||
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
|
||||
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
|
||||
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
|
||||
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
|
||||
|
||||
---
|
||||
|
||||
## v3.6.5 (2023-11-09)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
|
||||
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
|
||||
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
|
||||
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
|
||||
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
|
||||
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
|
||||
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
|
||||
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
|
||||
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
|
||||
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
|
||||
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
|
||||
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
|
||||
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
|
||||
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
|
||||
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
|
||||
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
|
||||
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
|
||||
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
|
||||
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
|
||||
|
||||
---
|
||||
|
||||
## v3.6.4 (2023-10-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12831](https://github.com/netbox-community/netbox/issues/12831) - Include circuit description in cable trace SVG image
|
||||
* [#12872](https://github.com/netbox-community/netbox/issues/12872) - Introduce the `DATA_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#13950](https://github.com/netbox-community/netbox/issues/13950) - Display custom choice field labels rather than values in UI
|
||||
* [#13957](https://github.com/netbox-community/netbox/issues/13957) - Add DNS name filter on IP addresses list
|
||||
* [#13962](https://github.com/netbox-community/netbox/issues/13962) - Add a copy-to-clipboard button for API tokens
|
||||
* [#13972](https://github.com/netbox-community/netbox/issues/13972) - Introduce a filter to find unterminated cables
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11987](https://github.com/netbox-community/netbox/issues/11987) - Fix validation of bulk cable updates via bulk import form
|
||||
* [#12328](https://github.com/netbox-community/netbox/issues/12328) - Ensure generic foreign key relationships are populated in REST API serializations of objects
|
||||
* [#12336](https://github.com/netbox-community/netbox/issues/12336) - Employ PostgreSQL advisory locks to avoid duplicate MPTT tree IDs when bulk creating objects
|
||||
* [#13064](https://github.com/netbox-community/netbox/issues/13064) - Fix resetting of checkbox fields triggered by HTMX form re-rendering
|
||||
* [#13440](https://github.com/netbox-community/netbox/issues/13440) - Fix support for assigning a tenant when creating "next available" VLANs via the REST API
|
||||
* [#13746](https://github.com/netbox-community/netbox/issues/13746) - Fix support for setting custom field values when creating "next available" IP addresses via the REST API
|
||||
* [#13872](https://github.com/netbox-community/netbox/issues/13872) - Add CSV delimiter field to file upload tab under bulk object upload views
|
||||
* [#13876](https://github.com/netbox-community/netbox/issues/13876) - Fix support for assigning an interface when creating "next available" IP addresses via the REST API
|
||||
* [#13910](https://github.com/netbox-community/netbox/issues/13910) - Correct "add device" button link under platform view
|
||||
* [#13944](https://github.com/netbox-community/netbox/issues/13944) - Correct serialization of several report attributes in the REST API
|
||||
* [#13966](https://github.com/netbox-community/netbox/issues/13966) - Restore "last login" column on users table
|
||||
* [#14013](https://github.com/netbox-community/netbox/issues/14013) - Fix device role filter choices under inventory items list filters
|
||||
* [#14023](https://github.com/netbox-community/netbox/issues/14023) - Fix exception when bulk disconnecting interfaces connected to the same cable
|
||||
* [#14025](https://github.com/netbox-community/netbox/issues/14025) - Fix exception when viewing a script that begins with the same name as another
|
||||
* [#14026](https://github.com/netbox-community/netbox/issues/14026) - Optimize the automatic creation of available IP addresses for large prefixes
|
||||
* [#14042](https://github.com/netbox-community/netbox/issues/14042) - Fix duplicated child object count decrements when removing objects in bulk
|
||||
|
||||
---
|
||||
|
||||
## v3.6.3 (2023-09-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#12732](https://github.com/netbox-community/netbox/issues/12732) - Add toggle to hide disconnected interfaces under device view
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11079](https://github.com/netbox-community/netbox/issues/11079) - Enable tracing cable paths across multiple cables in parallel
|
||||
* [#11901](https://github.com/netbox-community/netbox/issues/11901) - Fix `IndexError` exception when manipulating terminations for existing cables via REST API
|
||||
* [#13506](https://github.com/netbox-community/netbox/issues/13506) - Enable creating a config template which references a data file via the REST API
|
||||
* [#13666](https://github.com/netbox-community/netbox/issues/13666) - Cleanly handle reports without any test methods defined
|
||||
* [#13839](https://github.com/netbox-community/netbox/issues/13839) - Restore original text color for HTML code elements
|
||||
* [#13843](https://github.com/netbox-community/netbox/issues/13843) - Fix assignment of VLAN group scope during bulk edit
|
||||
* [#13845](https://github.com/netbox-community/netbox/issues/13845) - Fix `AttributeError` exception when attaching front/rear images to a device type
|
||||
* [#13849](https://github.com/netbox-community/netbox/issues/13849) - Fix `KeyError` exception when deleting an object which references a configured choice value that has been removed
|
||||
* [#13859](https://github.com/netbox-community/netbox/issues/13859) - Fix invalid response when searching for custom choice field values returns no matches
|
||||
* [#13864](https://github.com/netbox-community/netbox/issues/13864) - Correct default background color for dashboard widget headers
|
||||
* [#13871](https://github.com/netbox-community/netbox/issues/13871) - Fix rack filtering for empty location during device bulk import
|
||||
* [#13891](https://github.com/netbox-community/netbox/issues/13891) - Allow designating an IP address as primary for device/VM while assigning it to an interface
|
||||
|
||||
---
|
||||
|
||||
## v3.6.2 (2023-09-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -110,6 +110,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Command to clear the entire cache."""
|
||||
help = 'Clears the cache.'
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
# Fetch the current config revision from the cache
|
||||
config_version = cache.get('config_version')
|
||||
# Clear the cache
|
||||
cache.clear()
|
||||
self.stdout.write('Cache has been cleared.', ending="\n")
|
||||
if config_version:
|
||||
# Activate the current config revision
|
||||
ConfigRevision.objects.get(id=config_version).activate()
|
||||
self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from core.choices import DataSourceStatusChoices
|
||||
from core.models import DataSource
|
||||
|
||||
|
||||
@@ -33,9 +34,13 @@ class Command(BaseCommand):
|
||||
for i, datasource in enumerate(datasources, start=1):
|
||||
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
|
||||
self.stdout.flush()
|
||||
datasource.sync()
|
||||
self.stdout.write(datasource.get_status_display())
|
||||
self.stdout.flush()
|
||||
try:
|
||||
datasource.sync()
|
||||
self.stdout.write(datasource.get_status_display())
|
||||
self.stdout.flush()
|
||||
except Exception as e:
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
raise e
|
||||
|
||||
if len(options['name']) > 1:
|
||||
self.stdout.write(f"Finished.")
|
||||
|
||||
@@ -122,6 +122,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure URL scheme matches selected type
|
||||
if self.type == DataSourceTypeChoices.LOCAL and self.url_scheme not in ('file', ''):
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -84,6 +85,14 @@ class ManagedFile(SyncedDataMixin, models.Model):
|
||||
self.file_path = os.path.basename(self.data_path)
|
||||
self.data_file.write_to_disk(self.full_path, overwrite=True)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Ensure that the file root and path make a unique pair
|
||||
if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists():
|
||||
raise ValidationError(
|
||||
f"A {self._meta.verbose_name.lower()} with this file path already exists ({self.file_root}/{self.file_path}).")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete file from disk
|
||||
try:
|
||||
|
||||
@@ -229,7 +229,7 @@ class Job(models.Model):
|
||||
model_name=self.object_type.model,
|
||||
event=event,
|
||||
data=self.data,
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=self.user.username,
|
||||
retry=get_rq_retry()
|
||||
)
|
||||
|
||||
@@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
|
||||
)
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object'),
|
||||
linkify=True
|
||||
linkify=True,
|
||||
orderable=False
|
||||
)
|
||||
status = columns.ChoiceFieldColumn(
|
||||
verbose_name=_('Status'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
||||
from extras.models import ConfigRevision
|
||||
@@ -153,9 +154,11 @@ class ConfigView(generic.ObjectView):
|
||||
queryset = ConfigRevision.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if config := self.queryset.first():
|
||||
return config
|
||||
# Instantiate a dummy default config if none has been created yet
|
||||
return ConfigRevision(
|
||||
data=get_config().defaults
|
||||
)
|
||||
revision_id = cache.get('config_version')
|
||||
try:
|
||||
return ConfigRevision.objects.get(pk=revision_id)
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
return ConfigRevision(
|
||||
data=get_config()
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
|
||||
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -98,7 +98,7 @@ class PassThroughPortMixin(object):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(NetBoxModelViewSet):
|
||||
class RegionViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
@@ -114,7 +114,7 @@ class RegionViewSet(NetBoxModelViewSet):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
class SiteGroupViewSet(NetBoxModelViewSet):
|
||||
class SiteGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
SiteGroup.objects.all(),
|
||||
Site,
|
||||
@@ -149,7 +149,7 @@ class SiteViewSet(NetBoxModelViewSet):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationViewSet(NetBoxModelViewSet):
|
||||
class LocationViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
@@ -350,7 +350,7 @@ class DeviceBayTemplateViewSet(NetBoxModelViewSet):
|
||||
filterset_class = filtersets.DeviceBayTemplateFilterSet
|
||||
|
||||
|
||||
class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemTemplateViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
|
||||
serializer_class = serializers.InventoryItemTemplateSerializer
|
||||
filterset_class = filtersets.InventoryItemTemplateFilterSet
|
||||
@@ -538,7 +538,7 @@ class DeviceBayViewSet(NetBoxModelViewSet):
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
|
||||
class InventoryItemViewSet(NetBoxModelViewSet):
|
||||
class InventoryItemViewSet(MPTTLockedMixin, NetBoxModelViewSet):
|
||||
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
filterset_class = filtersets.InventoryItemFilterSet
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.filtersets import PrimaryIPFilterSet
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
|
||||
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
|
||||
class DeviceFilterSet(
|
||||
NetBoxModelFilterSet,
|
||||
TenancyFilterSet,
|
||||
ContactModelFilterSet,
|
||||
LocalConfigContextFilterSet,
|
||||
PrimaryIPFilterSet,
|
||||
):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device_type__manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_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)'),
|
||||
)
|
||||
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='oob_ip',
|
||||
queryset=IPAddress.objects.all(),
|
||||
@@ -1021,6 +1018,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(description_icontains=value.strip()) |
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
@@ -1069,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
@@ -1745,6 +1743,10 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
method='filter_by_cable_end_b',
|
||||
field_name='terminations__termination_id'
|
||||
)
|
||||
unterminated = django_filters.BooleanFilter(
|
||||
method='_unterminated',
|
||||
label=_('Unterminated'),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CableTypeChoices
|
||||
)
|
||||
@@ -1812,6 +1814,19 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
# Filter by termination id and cable_end type
|
||||
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
|
||||
|
||||
def _unterminated(self, queryset, name, value):
|
||||
if value:
|
||||
terminated_ids = (
|
||||
queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A)
|
||||
.filter(terminations__cable_end=CableEndChoices.SIDE_B)
|
||||
.values("id")
|
||||
)
|
||||
return queryset.exclude(id__in=terminated_ids)
|
||||
else:
|
||||
return queryset.filter(terminations__cable_end=CableEndChoices.SIDE_A).filter(
|
||||
terminations__cable_end=CableEndChoices.SIDE_B
|
||||
)
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
@@ -549,9 +549,9 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
}
|
||||
if 'location' in data:
|
||||
if location := data.get('location'):
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
f"location__{self.fields['location'].to_field_name}": location,
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
@@ -1192,7 +1192,7 @@ class CableImportForm(NetBoxModelImportForm):
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
if termination_object.cable is not None:
|
||||
if termination_object.cable is not None and termination_object.cable != self.instance:
|
||||
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
|
||||
except ObjectDoesNotExist:
|
||||
raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
|
||||
|
||||
@@ -109,7 +109,7 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Device type')
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
device_role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
required=False,
|
||||
label=_('Device role')
|
||||
@@ -164,6 +164,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||
status = forms.MultipleChoiceField(
|
||||
label=_('Status'),
|
||||
choices=SiteStatusChoices,
|
||||
@@ -247,6 +248,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -419,6 +421,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -543,6 +546,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
||||
)),
|
||||
(_('Weight'), ('weight', 'weight_unit')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -619,6 +623,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
||||
model = Platform
|
||||
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -653,6 +658,7 @@ class DeviceFilterForm(
|
||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||
))
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -910,7 +916,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
|
||||
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit', 'unterminated')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -979,6 +985,13 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
unterminated = forms.NullBooleanField(
|
||||
label=_('Unterminated'),
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -989,6 +1002,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -1136,7 +1150,7 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1158,7 +1172,7 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'speed')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1180,7 +1194,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1197,7 +1211,7 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1217,9 +1231,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
(_('PoE'), ('poe_mode', 'poe_type')),
|
||||
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'device_id')
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
@@ -1324,7 +1339,7 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
model = FrontPort
|
||||
@@ -1346,7 +1361,7 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'type', 'color')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Cable'), ('cabled', 'occupied')),
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1367,7 +1382,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'position')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
position = forms.CharField(
|
||||
@@ -1382,7 +1397,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1393,7 +1408,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
|
||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
|
||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
|
||||
)
|
||||
role_id = DynamicModelMultipleChoiceField(
|
||||
queryset=InventoryItemRole.objects.all(),
|
||||
|
||||
@@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
platform = DynamicModelChoiceField(
|
||||
label=_('Platform'),
|
||||
queryset=Platform.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
|
||||
@@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
|
||||
# positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front port templates to be created ({frontport_count}) must match the selected "
|
||||
"number of rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
@@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
)
|
||||
self.fields['rear_port'].choices = choices
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the number of FrontPorts to be created matches the selected number of RearPort positions
|
||||
frontport_count = len(self.cleaned_data['name'])
|
||||
rearport_count = len(self.cleaned_data['rear_port'])
|
||||
if frontport_count != rearport_count:
|
||||
raise forms.ValidationError({
|
||||
'rear_port': _(
|
||||
"The number of front ports to be created ({frontport_count}) must match the selected number of "
|
||||
"rear port positions ({rearport_count})."
|
||||
).format(
|
||||
frontport_count=frontport_count,
|
||||
rearport_count=rearport_count
|
||||
)
|
||||
})
|
||||
|
||||
def get_iterative_data(self, iteration):
|
||||
|
||||
# Assign rear port and position from selected set
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='starting_unit',
|
||||
field=models.PositiveSmallIntegerField(default=1),
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
]
|
||||
|
||||
22
netbox/dcim/migrations/0182_zero_length_cable_fix.py
Normal file
22
netbox/dcim/migrations/0182_zero_length_cable_fix.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def update_cable_lengths(apps, schema_editor):
|
||||
Cable = apps.get_model('dcim', 'Cable')
|
||||
|
||||
# Set the absolute length for any zero-length Cables
|
||||
Cable.objects.filter(length=0).update(_abs_length=0)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0181_rename_device_role_device_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=update_cable_lengths,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -20,7 +20,7 @@ from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from wireless.models import WirelessLink
|
||||
from .device_components import FrontPort, RearPort
|
||||
from .device_components import FrontPort, RearPort, PathEndpoint
|
||||
|
||||
__all__ = (
|
||||
'Cable',
|
||||
@@ -180,6 +180,17 @@ class Cable(PrimaryModel):
|
||||
if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
|
||||
raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
|
||||
|
||||
if a_type == b_type:
|
||||
# can't directly use self.a_terminations here as possible they
|
||||
# don't have pk yet
|
||||
a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
|
||||
b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
|
||||
|
||||
if (a_pks & b_pks):
|
||||
raise ValidationError(
|
||||
_("A and B terminations cannot connect to the same object.")
|
||||
)
|
||||
|
||||
# Run clean() on any new CableTerminations
|
||||
for termination in self.a_terminations:
|
||||
CableTermination(cable=self, cable_end='A', termination=termination).clean()
|
||||
@@ -190,7 +201,7 @@ class Cable(PrimaryModel):
|
||||
_created = self.pk is None
|
||||
|
||||
# Store the given length (if any) in meters for use in database ordering
|
||||
if self.length and self.length_unit:
|
||||
if self.length is not None and self.length_unit:
|
||||
self._abs_length = to_meters(self.length, self.length_unit)
|
||||
else:
|
||||
self._abs_length = None
|
||||
@@ -518,9 +529,16 @@ class CablePath(models.Model):
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
if len(set(t.link for t in terminations)) > 1:
|
||||
if len(set(t.link for t in terminations)) > 1 and (
|
||||
position_stack and len(terminations) != len(position_stack[-1])
|
||||
):
|
||||
is_split = True
|
||||
break
|
||||
|
||||
@@ -529,46 +547,68 @@ class CablePath(models.Model):
|
||||
object_to_path_node(t) for t in terminations
|
||||
])
|
||||
|
||||
# Step 2: Determine the attached link (Cable or WirelessLink), if any
|
||||
link = terminations[0].link
|
||||
if link is None and len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
elif link is None:
|
||||
# Step 2: Determine the attached links (Cable or WirelessLink), if any
|
||||
links = [termination.link for termination in terminations if termination.link is not None]
|
||||
if len(links) == 0:
|
||||
if len(path) == 1:
|
||||
# If this is the start of the path and no link exists, return None
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert type(link) in (Cable, WirelessLink)
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
|
||||
# Step 3: Record the link and update path status if not "connected"
|
||||
path.append([object_to_path_node(link)])
|
||||
if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED:
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
if len(not_connected_terminations) > 0:
|
||||
is_complete = False
|
||||
is_split = True
|
||||
|
||||
# Step 4: Record the links, keeping cables in order to allow for SVG rendering
|
||||
cables = []
|
||||
for link in links:
|
||||
if object_to_path_node(link) not in cables:
|
||||
cables.append(object_to_path_node(link))
|
||||
path.append(cables)
|
||||
|
||||
# Step 5: Update the path status if a link is not connected
|
||||
links_status = [link.status for link in links if link.status != LinkStatusChoices.STATUS_CONNECTED]
|
||||
if any([status != LinkStatusChoices.STATUS_CONNECTED for status in links_status]):
|
||||
is_active = False
|
||||
|
||||
# Step 4: Determine the far-end terminations
|
||||
if isinstance(link, Cable):
|
||||
# Step 6: Determine the far-end terminations
|
||||
if isinstance(links[0], Cable):
|
||||
termination_type = ContentType.objects.get_for_model(terminations[0])
|
||||
local_cable_terminations = CableTermination.objects.filter(
|
||||
termination_type=termination_type,
|
||||
termination_id__in=[t.pk for t in terminations]
|
||||
)
|
||||
# Terminations must all belong to same end of Cable
|
||||
local_cable_end = local_cable_terminations[0].cable_end
|
||||
assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:])
|
||||
remote_cable_terminations = CableTermination.objects.filter(
|
||||
cable=link,
|
||||
cable_end='A' if local_cable_end == 'B' else 'B'
|
||||
)
|
||||
|
||||
q_filter = Q()
|
||||
for lct in local_cable_terminations:
|
||||
cable_end = 'A' if lct.cable_end == 'B' else 'B'
|
||||
q_filter |= Q(cable=lct.cable, cable_end=cable_end)
|
||||
|
||||
remote_cable_terminations = CableTermination.objects.filter(q_filter)
|
||||
remote_terminations = [ct.termination for ct in remote_cable_terminations]
|
||||
else:
|
||||
# WirelessLink
|
||||
remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a]
|
||||
remote_terminations = [
|
||||
link.interface_b if link.interface_a is terminations[0] else link.interface_a for link in links
|
||||
]
|
||||
|
||||
# Step 5: Record the far-end termination object(s)
|
||||
# Remote Terminations must all be of the same type, otherwise return a split path
|
||||
if not all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
# Step 7: Record the far-end termination object(s)
|
||||
path.append([
|
||||
object_to_path_node(t) for t in remote_terminations if t is not None
|
||||
])
|
||||
|
||||
# Step 6: Determine the "next hop" terminations, if applicable
|
||||
# Step 8: Determine the "next hop" terminations, if applicable
|
||||
if not remote_terminations:
|
||||
break
|
||||
|
||||
@@ -577,20 +617,32 @@ class CablePath(models.Model):
|
||||
rear_ports = RearPort.objects.filter(
|
||||
pk__in=[t.rear_port_id for t in remote_terminations]
|
||||
)
|
||||
if len(rear_ports) > 1:
|
||||
assert all(rp.positions == 1 for rp in rear_ports)
|
||||
elif rear_ports[0].positions > 1:
|
||||
if len(rear_ports) > 1 or rear_ports[0].positions > 1:
|
||||
position_stack.append([fp.rear_port_position for fp in remote_terminations])
|
||||
|
||||
terminations = rear_ports
|
||||
|
||||
elif isinstance(remote_terminations[0], RearPort):
|
||||
|
||||
if len(remote_terminations) > 1 or remote_terminations[0].positions == 1:
|
||||
if len(remote_terminations) == 1 and remote_terminations[0].positions == 1:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id__in=[rp.pk for rp in remote_terminations],
|
||||
rear_port_position=1
|
||||
)
|
||||
# Obtain the individual front ports based on the termination and all positions
|
||||
elif len(remote_terminations) > 1 and position_stack:
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
front_ports = FrontPort.objects.filter(
|
||||
rear_port_id=remote_terminations[0].pk,
|
||||
@@ -632,9 +684,16 @@ class CablePath(models.Model):
|
||||
|
||||
terminations = [circuit_termination]
|
||||
|
||||
# Anything else marks the end of the path
|
||||
else:
|
||||
is_complete = True
|
||||
# Check for non-symmetric path
|
||||
if all(isinstance(t, type(remote_terminations[0])) for t in remote_terminations[1:]):
|
||||
is_complete = True
|
||||
elif len(remote_terminations) == 0:
|
||||
is_complete = False
|
||||
else:
|
||||
# Unsupported topology, mark as split and exit
|
||||
is_complete = False
|
||||
is_split = True
|
||||
break
|
||||
|
||||
return cls(
|
||||
@@ -740,3 +799,15 @@ class CablePath(models.Model):
|
||||
return [
|
||||
ct.get_peer_termination() for ct in nodes
|
||||
]
|
||||
|
||||
def get_asymmetric_nodes(self):
|
||||
"""
|
||||
Return all available next segments in a split cable path.
|
||||
"""
|
||||
from circuits.models import CircuitTermination
|
||||
asymmetric_nodes = []
|
||||
for nodes in self.path_objects:
|
||||
if type(nodes[0]) in [RearPort, FrontPort, CircuitTermination]:
|
||||
asymmetric_nodes.extend([node for node in nodes if node.link is None])
|
||||
|
||||
return asymmetric_nodes
|
||||
|
||||
@@ -4,6 +4,7 @@ import yaml
|
||||
from functools import cached_property
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import F, ProtectedError
|
||||
@@ -15,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.models import ConfigContextModel, CustomField
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
@@ -332,10 +333,10 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
ret = super().save(*args, **kwargs)
|
||||
|
||||
# Delete any previously uploaded image files that are no longer in use
|
||||
if self.front_image != self._original_front_image:
|
||||
self._original_front_image.delete(save=False)
|
||||
if self.rear_image != self._original_rear_image:
|
||||
self._original_rear_image.delete(save=False)
|
||||
if self._original_front_image and self.front_image != self._original_front_image:
|
||||
default_storage.delete(self._original_front_image)
|
||||
if self._original_rear_image and self.rear_image != self._original_rear_image:
|
||||
default_storage.delete(self._original_rear_image)
|
||||
|
||||
return ret
|
||||
|
||||
@@ -984,11 +985,17 @@ class Device(
|
||||
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 not components:
|
||||
return
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
model = queryset.model.component_model
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||
for component in components:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
if bulk_create:
|
||||
components = [obj.instantiate(device=self) for obj in queryset]
|
||||
if not components:
|
||||
return
|
||||
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:
|
||||
@@ -1001,8 +1008,7 @@ class Device(
|
||||
update_fields=None
|
||||
)
|
||||
else:
|
||||
for obj in queryset:
|
||||
component = obj.instantiate(device=self)
|
||||
for component in components:
|
||||
component.save()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError(_(
|
||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
||||
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
|
||||
).format(
|
||||
rack=self.rack,
|
||||
rack_site=self.rack.site,
|
||||
|
||||
@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
starting_unit = models.PositiveSmallIntegerField(
|
||||
default=RACK_STARTING_UNIT_DEFAULT,
|
||||
verbose_name=_('starting unit'),
|
||||
validators=[MinValueValidator(1),],
|
||||
help_text=_('Starting unit for rack')
|
||||
)
|
||||
desc_units = models.BooleanField(
|
||||
|
||||
@@ -32,11 +32,18 @@ class Node(Hyperlink):
|
||||
color: Box fill color (RRGGBB format)
|
||||
labels: An iterable of text strings. Each label will render on a new line within the box.
|
||||
radius: Box corner radius, for rounded corners (default: 10)
|
||||
object: A copy of the object to allow reference when drawing cables to determine which cables are connected to
|
||||
which terminations.
|
||||
"""
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, **extra):
|
||||
object = None
|
||||
|
||||
def __init__(self, position, width, url, color, labels, radius=10, object=object, **extra):
|
||||
super(Node, self).__init__(href=url, target='_parent', **extra)
|
||||
|
||||
# Save object for reference by cable systems
|
||||
self.object = object
|
||||
|
||||
x, y = position
|
||||
|
||||
# Add the box
|
||||
@@ -77,7 +84,7 @@ class Connector(Group):
|
||||
labels: Iterable of text labels
|
||||
"""
|
||||
|
||||
def __init__(self, start, url, color, labels=[], **extra):
|
||||
def __init__(self, start, url, color, labels=[], description=[], **extra):
|
||||
super().__init__(class_='connector', **extra)
|
||||
|
||||
self.start = start
|
||||
@@ -104,6 +111,8 @@ class Connector(Group):
|
||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
if len(description) > 0:
|
||||
link.set_desc("\n".join(description))
|
||||
|
||||
self.add(link)
|
||||
|
||||
@@ -151,6 +160,8 @@ class CableTraceSVG:
|
||||
elif instance._meta.model_name == 'circuit':
|
||||
labels[0] = f'Circuit {instance}'
|
||||
labels.append(instance.provider)
|
||||
if instance.description:
|
||||
labels.append(instance.description)
|
||||
elif instance._meta.model_name == 'circuittermination':
|
||||
if instance.xconnect_id:
|
||||
labels.append(f'{instance.xconnect_id}')
|
||||
@@ -206,7 +217,8 @@ class CableTraceSVG:
|
||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||
color=self._get_color(term),
|
||||
labels=self._get_labels(term),
|
||||
radius=5
|
||||
radius=5,
|
||||
object=term
|
||||
)
|
||||
nodes_height = max(nodes_height, node.box['height'])
|
||||
nodes.append(node)
|
||||
@@ -238,22 +250,65 @@ class CableTraceSVG:
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_cable(self, cable):
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
def draw_cable(self, cable, terminations, cable_count=0):
|
||||
"""
|
||||
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||
|
||||
:param cable: The cable to draw
|
||||
:param terminations: List of terminations to build positioning data off of
|
||||
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||
tooltip.
|
||||
"""
|
||||
|
||||
# If the cable count is higher than 2, collapse the description into a tooltip
|
||||
if cable_count > 2:
|
||||
# Use the cable __str__ function to denote the cable
|
||||
labels = [f'{cable}']
|
||||
|
||||
# Include the label and the status description in the tooltip
|
||||
description = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
|
||||
# If there is only one termination, center on that termination
|
||||
# Otherwise average the center across the terminations
|
||||
if len(terminations) == 1:
|
||||
center = terminations[0].bottom_center[0]
|
||||
else:
|
||||
# Get a list of termination centers
|
||||
termination_centers = [term.bottom_center[0] for term in terminations]
|
||||
# Average the centers
|
||||
center = sum(termination_centers) / len(termination_centers)
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=(self.center + OFFSET, self.cursor),
|
||||
start=(center, self.cursor),
|
||||
color=cable.color or '000000',
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
labels=labels
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Set the cursor position
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
@@ -334,34 +389,52 @@ class CableTraceSVG:
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link = links[0] # Remove Cable from list
|
||||
link_cables = {}
|
||||
fanin = False
|
||||
fanout = False
|
||||
|
||||
# Cable
|
||||
if type(link) is Cable:
|
||||
# Determine if we have fanins or fanouts
|
||||
if len(near_ends) > len(set(links)):
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
fanin = True
|
||||
if len(far_ends) > len(set(links)):
|
||||
fanout = True
|
||||
cursor = self.cursor
|
||||
for link in links:
|
||||
# Cable
|
||||
if type(link) is Cable and not link_cables.get(link.pk):
|
||||
# Reset cursor
|
||||
self.cursor = cursor
|
||||
# Generate a list of terminations connected to this cable
|
||||
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
|
||||
# Draw the cable
|
||||
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
|
||||
# Add cable to the list of cables
|
||||
link_cables.update({link.pk: cable})
|
||||
# Add cable to drawing
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Account for fan-ins height
|
||||
if len(near_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1 and fanin:
|
||||
for term in terminations:
|
||||
if term.object.cable == link:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
cable = self.draw_cable(link)
|
||||
self.connectors.append(cable)
|
||||
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1:
|
||||
for term in terminations:
|
||||
self.draw_fanin(term, cable)
|
||||
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
|
||||
# Far end termination(s)
|
||||
if len(far_ends) > 1:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
self.draw_fanout(term, cable)
|
||||
if fanout:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
|
||||
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
|
||||
else:
|
||||
self.draw_terminations(far_ends)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
|
||||
@@ -64,9 +64,19 @@ def get_interface_state_attribute(record):
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.enabled:
|
||||
return "enabled"
|
||||
return 'enabled'
|
||||
else:
|
||||
return "disabled"
|
||||
return 'disabled'
|
||||
|
||||
|
||||
def get_interface_connected_attribute(record):
|
||||
"""
|
||||
Get interface disconnected state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.mark_connected or record.cable:
|
||||
return 'connected'
|
||||
else:
|
||||
return 'disconnected'
|
||||
|
||||
|
||||
#
|
||||
@@ -456,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
'args': [Accessor('device_id')],
|
||||
}
|
||||
)
|
||||
maximum_draw = tables.Column(
|
||||
verbose_name=_('Maximum draw (W)')
|
||||
)
|
||||
allocated_draw = tables.Column(
|
||||
verbose_name=_('Allocated draw (W)')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:powerport_list'
|
||||
)
|
||||
@@ -615,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
verbose_name=_('VRF'),
|
||||
linkify=True
|
||||
)
|
||||
inventory_items = tables.ManyToManyColumn(
|
||||
linkify_item=True,
|
||||
verbose_name=_('Inventory Items'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:interface_list'
|
||||
)
|
||||
@@ -626,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'speed', 'speed_formatted', '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', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@@ -674,6 +694,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
'data-connected': get_interface_connected_attribute
|
||||
}
|
||||
|
||||
|
||||
@@ -922,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
discovered = columns.BooleanColumn(
|
||||
verbose_name=_('Discovered'),
|
||||
)
|
||||
parent = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name=_('Parent'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:inventoryitem_list'
|
||||
)
|
||||
@@ -930,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -1053,7 +1078,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
comments = columns.MarkdownColumn()
|
||||
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:vdc_list'
|
||||
url_name='dcim:virtualdevicecontext_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
|
||||
@@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
linkify=True,
|
||||
verbose_name=_('Tenant')
|
||||
)
|
||||
site = tables.Column(
|
||||
accessor='rack__site',
|
||||
linkify=True,
|
||||
verbose_name=_('Site'),
|
||||
)
|
||||
comments = columns.MarkdownColumn(
|
||||
verbose_name=_('Comments'),
|
||||
)
|
||||
@@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
|
||||
'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
|
||||
'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
||||
@@ -15,6 +15,7 @@ class CablePathTestCase(TestCase):
|
||||
1XX: Test direct connections between different endpoint types
|
||||
2XX: Test different cable topologies
|
||||
3XX: Test responses to changes in existing objects
|
||||
4XX: Test to exclude specific cable topologies
|
||||
"""
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -33,12 +34,11 @@ class CablePathTestCase(TestCase):
|
||||
circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type')
|
||||
cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1')
|
||||
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
def _get_cablepath(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists.
|
||||
Return a given cable path
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
:param is_active: Boolean indicating whether the end-to-end path is complete and active (optional)
|
||||
|
||||
:return: The matching CablePath (if any)
|
||||
"""
|
||||
@@ -48,12 +48,29 @@ class CablePathTestCase(TestCase):
|
||||
path.append([object_to_path_node(node) for node in step])
|
||||
else:
|
||||
path.append([object_to_path_node(step)])
|
||||
return CablePath.objects.filter(path=path, **kwargs).first()
|
||||
|
||||
cablepath = CablePath.objects.filter(path=path, **kwargs).first()
|
||||
def assertPathExists(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a CablePath from origin to destination with a specific intermediate path exists. Returns the
|
||||
first matching CablePath, if found.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNotNone(cablepath, msg='CablePath not found')
|
||||
|
||||
return cablepath
|
||||
|
||||
def assertPathDoesNotExist(self, nodes, **kwargs):
|
||||
"""
|
||||
Assert that a specific CablePath does *not* exist.
|
||||
|
||||
:param nodes: Iterable of steps, with each step being either a single node or a list of nodes
|
||||
"""
|
||||
cablepath = self._get_cablepath(nodes, **kwargs)
|
||||
self.assertIsNone(cablepath, msg='Unexpected CablePath found')
|
||||
|
||||
def assertPathIsSet(self, origin, cablepath, msg=None):
|
||||
"""
|
||||
Assert that a specific CablePath instance is set as the path on the origin.
|
||||
@@ -1695,6 +1712,291 @@ class CablePathTestCase(TestCase):
|
||||
self.assertPathIsSet(interface3, path3)
|
||||
self.assertPathIsSet(interface4, path4)
|
||||
|
||||
def test_219_interface_to_interface_duplex_via_multiple_rearports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4), (rearport2, rearport4), (frontport2, frontport4)),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 1)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4]
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2]
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4]
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1]
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3]
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2]
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport3), (cable1, cable5), (interface1, interface3)
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_221_non_symmetric_paths(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
|
||||
[IF2] --C5-- [FP3] [RP3] --C4-- [RP4] [FP4] --C6-- [FP5] [RP5] --C7-- [RP6] [FP6] --C3---/
|
||||
"""
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1)
|
||||
rearport5 = RearPort.objects.create(device=self.device, name='Rear Port 5', positions=1)
|
||||
rearport6 = RearPort.objects.create(device=self.device, name='Rear Port 6', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
frontport5 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 5', rear_port=rearport5, rear_port_position=1
|
||||
)
|
||||
frontport6 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 6', rear_port=rearport6, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
cable6 = Cable(
|
||||
a_terminations=[frontport4],
|
||||
b_terminations=[frontport5],
|
||||
label='C6'
|
||||
)
|
||||
cable6.save()
|
||||
cable7 = Cable(
|
||||
a_terminations=[rearport5],
|
||||
b_terminations=[rearport6],
|
||||
label='C7'
|
||||
)
|
||||
cable7.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1],
|
||||
label='C1'
|
||||
)
|
||||
cable1.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
# Create cable1
|
||||
cable5 = Cable(
|
||||
a_terminations=[interface3],
|
||||
b_terminations=[frontport3],
|
||||
label='C5'
|
||||
)
|
||||
cable5.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 2)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport6],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
cable3.save()
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport6), (rearport2, rearport6), (cable2, cable7),
|
||||
(rearport1, rearport5), (frontport1, frontport5), (cable1, cable6)
|
||||
),
|
||||
is_complete=False,
|
||||
is_split=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathExists(
|
||||
(
|
||||
interface3, cable5, frontport3, rearport3, cable4, rearport4, frontport4, cable6, frontport5, rearport5,
|
||||
cable7, rearport6, frontport6, cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 3)
|
||||
|
||||
def test_301_create_path_via_existing_cable(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
|
||||
@@ -1845,3 +2147,93 @@ class CablePathTestCase(TestCase):
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
def test_401_exclude_midspan_devices(self):
|
||||
"""
|
||||
[IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]
|
||||
[FP3][Test mid-span Device][RP3] --C4-- [RP4][Test mid-span Device][FP4] /
|
||||
"""
|
||||
device = Device.objects.create(
|
||||
site=self.site,
|
||||
device_type=self.device.device_type,
|
||||
device_role=self.device.device_role,
|
||||
name='Test mid-span Device'
|
||||
)
|
||||
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
|
||||
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
|
||||
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1)
|
||||
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1)
|
||||
rearport3 = RearPort.objects.create(device=device, name='Rear Port 3', positions=1)
|
||||
rearport4 = RearPort.objects.create(device=device, name='Rear Port 4', positions=1)
|
||||
frontport1 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1
|
||||
)
|
||||
frontport2 = FrontPort.objects.create(
|
||||
device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1
|
||||
)
|
||||
frontport3 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 3', rear_port=rearport3, rear_port_position=1
|
||||
)
|
||||
frontport4 = FrontPort.objects.create(
|
||||
device=device, name='Front Port 4', rear_port=rearport4, rear_port_position=1
|
||||
)
|
||||
|
||||
cable2 = Cable(
|
||||
a_terminations=[rearport1],
|
||||
b_terminations=[rearport2],
|
||||
label='C2'
|
||||
)
|
||||
cable2.save()
|
||||
cable4 = Cable(
|
||||
a_terminations=[rearport3],
|
||||
b_terminations=[rearport4],
|
||||
label='C4'
|
||||
)
|
||||
cable4.save()
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable1
|
||||
cable1 = Cable(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4)
|
||||
),
|
||||
is_complete=False
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
# Create cable 3
|
||||
cable3 = Cable(
|
||||
a_terminations=[frontport2, frontport4],
|
||||
b_terminations=[interface2],
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface2, cable3, (frontport2, frontport4), (rearport2, rearport4), (cable2, cable4),
|
||||
(rearport1, rearport3), (frontport1, frontport2), cable1, interface1
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertPathDoesNotExist(
|
||||
(
|
||||
interface1, cable1, (frontport1, frontport3), (rearport1, rearport3), (cable2, cable4),
|
||||
(rearport2, rearport4), (frontport2, frontport4), cable3, interface2
|
||||
),
|
||||
is_complete=True,
|
||||
is_active=True
|
||||
)
|
||||
self.assertEqual(CablePath.objects.count(), 0)
|
||||
|
||||
@@ -4275,6 +4275,7 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Interface(device=devices[4], name='Interface 10', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 11', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 12', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
Interface(device=devices[5], name='Interface 13', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
@@ -4290,6 +4291,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
|
||||
Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save()
|
||||
|
||||
# Cable for unterminated test
|
||||
Cable(a_terminations=[interfaces[12]], label='Cable 8', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_DECOMMISSIONING).save()
|
||||
|
||||
def test_label(self):
|
||||
params = {'label': ['Cable 1', 'Cable 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
@@ -4368,6 +4372,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_unterminated(self):
|
||||
params = {'unterminated': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'unterminated': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
|
||||
|
||||
|
||||
class PowerPanelTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = PowerPanel.objects.all()
|
||||
@@ -4702,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
addresses = (
|
||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||
IPAddress(assigned_object=None, address='10.1.1.3/24'),
|
||||
IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
|
||||
IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
|
||||
IPAddress(assigned_object=None, address='2001:db8::3/64'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(addresses)
|
||||
|
||||
vdcs[0].primary_ip4 = addresses[0]
|
||||
vdcs[0].primary_ip6 = addresses[3]
|
||||
vdcs[0].save()
|
||||
vdcs[1].primary_ip4 = addresses[1]
|
||||
vdcs[1].primary_ip6 = addresses[4]
|
||||
vdcs[1].save()
|
||||
|
||||
def test_device(self):
|
||||
@@ -4728,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from dcim.choices import *
|
||||
from dcim.models import *
|
||||
from extras.models import CustomField
|
||||
from tenancy.models import Tenant
|
||||
from utilities.utils import drange
|
||||
|
||||
@@ -255,6 +257,23 @@ class DeviceTestCase(TestCase):
|
||||
)
|
||||
DeviceRole.objects.bulk_create(roles)
|
||||
|
||||
# Create a CustomField with a default value & assign it to all component models
|
||||
cf1 = CustomField.objects.create(name='cf1', default='foo')
|
||||
cf1.content_types.set(
|
||||
ContentType.objects.filter(app_label='dcim', model__in=[
|
||||
'consoleport',
|
||||
'consoleserverport',
|
||||
'powerport',
|
||||
'poweroutlet',
|
||||
'interface',
|
||||
'rearport',
|
||||
'frontport',
|
||||
'modulebay',
|
||||
'devicebay',
|
||||
'inventoryitem',
|
||||
])
|
||||
)
|
||||
|
||||
# Create DeviceType components
|
||||
ConsolePortTemplate(
|
||||
device_type=device_type,
|
||||
@@ -266,18 +285,18 @@ class DeviceTestCase(TestCase):
|
||||
name='Console Server Port 1'
|
||||
).save()
|
||||
|
||||
ppt = PowerPortTemplate(
|
||||
powerport = PowerPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
ppt.save()
|
||||
powerport.save()
|
||||
|
||||
PowerOutletTemplate(
|
||||
device_type=device_type,
|
||||
name='Power Outlet 1',
|
||||
power_port=ppt,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
).save()
|
||||
|
||||
@@ -288,19 +307,19 @@ class DeviceTestCase(TestCase):
|
||||
mgmt_only=True
|
||||
).save()
|
||||
|
||||
rpt = RearPortTemplate(
|
||||
rearport = RearPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
rpt.save()
|
||||
rearport.save()
|
||||
|
||||
FrontPortTemplate(
|
||||
device_type=device_type,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rpt,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
).save()
|
||||
|
||||
@@ -314,73 +333,93 @@ class DeviceTestCase(TestCase):
|
||||
name='Device Bay 1'
|
||||
).save()
|
||||
|
||||
InventoryItemTemplate(
|
||||
device_type=device_type,
|
||||
name='Inventory Item 1'
|
||||
).save()
|
||||
|
||||
def test_device_creation(self):
|
||||
"""
|
||||
Ensure that all Device components are copied automatically from the DeviceType.
|
||||
"""
|
||||
d = Device(
|
||||
device = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name='Test Device 1'
|
||||
)
|
||||
d.save()
|
||||
device.save()
|
||||
|
||||
ConsolePort.objects.get(
|
||||
device=d,
|
||||
consoleport = ConsolePort.objects.get(
|
||||
device=device,
|
||||
name='Console Port 1'
|
||||
)
|
||||
self.assertEqual(consoleport.cf['cf1'], 'foo')
|
||||
|
||||
ConsoleServerPort.objects.get(
|
||||
device=d,
|
||||
consoleserverport = ConsoleServerPort.objects.get(
|
||||
device=device,
|
||||
name='Console Server Port 1'
|
||||
)
|
||||
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
|
||||
|
||||
pp = PowerPort.objects.get(
|
||||
device=d,
|
||||
powerport = PowerPort.objects.get(
|
||||
device=device,
|
||||
name='Power Port 1',
|
||||
maximum_draw=1000,
|
||||
allocated_draw=500
|
||||
)
|
||||
self.assertEqual(powerport.cf['cf1'], 'foo')
|
||||
|
||||
PowerOutlet.objects.get(
|
||||
device=d,
|
||||
poweroutlet = PowerOutlet.objects.get(
|
||||
device=device,
|
||||
name='Power Outlet 1',
|
||||
power_port=pp,
|
||||
power_port=powerport,
|
||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||
)
|
||||
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
|
||||
|
||||
Interface.objects.get(
|
||||
device=d,
|
||||
interface = Interface.objects.get(
|
||||
device=device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
)
|
||||
self.assertEqual(interface.cf['cf1'], 'foo')
|
||||
|
||||
rp = RearPort.objects.get(
|
||||
device=d,
|
||||
rearport = RearPort.objects.get(
|
||||
device=device,
|
||||
name='Rear Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
positions=8
|
||||
)
|
||||
self.assertEqual(rearport.cf['cf1'], 'foo')
|
||||
|
||||
FrontPort.objects.get(
|
||||
device=d,
|
||||
frontport = FrontPort.objects.get(
|
||||
device=device,
|
||||
name='Front Port 1',
|
||||
type=PortTypeChoices.TYPE_8P8C,
|
||||
rear_port=rp,
|
||||
rear_port=rearport,
|
||||
rear_port_position=2
|
||||
)
|
||||
self.assertEqual(frontport.cf['cf1'], 'foo')
|
||||
|
||||
ModuleBay.objects.get(
|
||||
device=d,
|
||||
modulebay = ModuleBay.objects.get(
|
||||
device=device,
|
||||
name='Module Bay 1'
|
||||
)
|
||||
self.assertEqual(modulebay.cf['cf1'], 'foo')
|
||||
|
||||
DeviceBay.objects.get(
|
||||
device=d,
|
||||
devicebay = DeviceBay.objects.get(
|
||||
device=device,
|
||||
name='Device Bay 1'
|
||||
)
|
||||
self.assertEqual(devicebay.cf['cf1'], 'foo')
|
||||
|
||||
inventoryitem = InventoryItem.objects.get(
|
||||
device=device,
|
||||
name='Inventory Item 1'
|
||||
)
|
||||
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
|
||||
|
||||
def test_multiple_unnamed_devices(self):
|
||||
|
||||
|
||||
@@ -122,16 +122,18 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
||||
if form.is_valid():
|
||||
|
||||
with transaction.atomic():
|
||||
|
||||
count = 0
|
||||
cable_ids = set()
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
if obj.cable is None:
|
||||
continue
|
||||
obj.cable.delete()
|
||||
count += 1
|
||||
if obj.cable:
|
||||
cable_ids.add(obj.cable.pk)
|
||||
count += 1
|
||||
for cable in Cable.objects.filter(pk__in=cable_ids):
|
||||
cable.delete()
|
||||
|
||||
messages.success(request, "Disconnected {} {}".format(
|
||||
count, self.queryset.model._meta.verbose_name_plural
|
||||
messages.success(request, _("Disconnected {count} {type}").format(
|
||||
count=count,
|
||||
type=self.queryset.model._meta.verbose_name_plural
|
||||
))
|
||||
|
||||
return redirect(return_url)
|
||||
@@ -693,8 +695,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
|
||||
label=_('Reservations'),
|
||||
badge=lambda obj: obj.reservations.count(),
|
||||
permission='dcim.view_rackreservation',
|
||||
weight=510,
|
||||
hide_if_empty=True
|
||||
weight=510
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -2991,6 +2992,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
|
||||
template_name = 'dcim/inventoryitem_bulk_delete.html'
|
||||
|
||||
|
||||
@register_model_view(InventoryItem, 'children')
|
||||
class InventoryItemChildrenView(generic.ObjectChildrenView):
|
||||
queryset = InventoryItem.objects.all()
|
||||
child_model = InventoryItem
|
||||
table = tables.InventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
template_name = 'generic/object_children.html'
|
||||
tab = ViewTab(
|
||||
label=_('Children'),
|
||||
badge=lambda obj: obj.child_items.count(),
|
||||
permission='dcim.view_inventoryitem',
|
||||
hide_if_empty=True,
|
||||
weight=5000
|
||||
)
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.child_items.restrict(request.user, 'view')
|
||||
|
||||
|
||||
#
|
||||
# Inventory item roles
|
||||
#
|
||||
|
||||
@@ -454,7 +454,7 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
|
||||
required=False
|
||||
)
|
||||
data_file = NestedDataFileSerializer(
|
||||
read_only=True
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -82,7 +82,10 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
|
||||
data = [
|
||||
{'id': c[0], 'display': c[1]} for c in page
|
||||
]
|
||||
return self.get_paginated_response(data)
|
||||
else:
|
||||
data = []
|
||||
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
|
||||
#
|
||||
@@ -280,7 +283,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Retrieve and run the Report. This will create a new Job.
|
||||
module, report_cls = self._get_report(pk)
|
||||
report = report_cls()
|
||||
report = report_cls
|
||||
input_serializer = serializers.ReportInputSerializer(
|
||||
data=request.data,
|
||||
context={'report': report}
|
||||
|
||||
@@ -244,3 +244,39 @@ class ChangeActionChoices(ChoiceSet):
|
||||
(ACTION_UPDATE, _('Update'), 'blue'),
|
||||
(ACTION_DELETE, _('Delete'), 'red'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Dashboard widgets
|
||||
#
|
||||
|
||||
class DashboardWidgetColorChoices(ChoiceSet):
|
||||
BLUE = 'blue'
|
||||
INDIGO = 'indigo'
|
||||
PURPLE = 'purple'
|
||||
PINK = 'pink'
|
||||
RED = 'red'
|
||||
ORANGE = 'orange'
|
||||
YELLOW = 'yellow'
|
||||
GREEN = 'green'
|
||||
TEAL = 'teal'
|
||||
CYAN = 'cyan'
|
||||
GRAY = 'gray'
|
||||
BLACK = 'black'
|
||||
WHITE = 'white'
|
||||
|
||||
CHOICES = (
|
||||
(BLUE, _('Blue')),
|
||||
(INDIGO, _('Indigo')),
|
||||
(PURPLE, _('Purple')),
|
||||
(PINK, _('Pink')),
|
||||
(RED, _('Red')),
|
||||
(ORANGE, _('Orange')),
|
||||
(YELLOW, _('Yellow')),
|
||||
(GREEN, _('Green')),
|
||||
(TEAL, _('Teal')),
|
||||
(CYAN, _('Cyan')),
|
||||
(GRAY, _('Gray')),
|
||||
(BLACK, _('Black')),
|
||||
(WHITE, _('White')),
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ from django import forms
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import DashboardWidgetColorChoices
|
||||
from netbox.registry import registry
|
||||
from utilities.forms import BootstrapMixin, add_blank_choice
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
__all__ = (
|
||||
'DashboardWidgetAddForm',
|
||||
@@ -21,7 +21,7 @@ class DashboardWidgetForm(BootstrapMixin, forms.Form):
|
||||
required=False
|
||||
)
|
||||
color = forms.ChoiceField(
|
||||
choices=add_blank_choice(ButtonColorChoices),
|
||||
choices=add_blank_choice(DashboardWidgetColorChoices),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,8 +122,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(extra_choices__contains=value)
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
def filter_by_choice(self, queryset, name, value):
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.forms import SimpleArrayField
|
||||
@@ -76,7 +78,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
extra_choices = SimpleArrayField(
|
||||
base_field=forms.CharField(),
|
||||
required=False,
|
||||
help_text=_('Comma-separated list of field choices')
|
||||
help_text=_(
|
||||
'Quoted string of comma-separated field choices with optional labels separated by colon: '
|
||||
'"choice1:First Choice,choice2:Second Choice"'
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -85,6 +90,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||
)
|
||||
|
||||
def clean_extra_choices(self):
|
||||
if isinstance(self.cleaned_data['extra_choices'], list):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices']:
|
||||
try:
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
return data
|
||||
|
||||
|
||||
class CustomLinkImportForm(CSVModelForm):
|
||||
content_types = CSVMultipleContentTypeField(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -95,19 +96,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
||||
required=False,
|
||||
help_text=mark_safe(_(
|
||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||
'comma. Example:'
|
||||
) + ' <code>choice1,First Choice</code>')
|
||||
'colon. Example:'
|
||||
) + ' <code>choice1:First Choice</code>')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomFieldChoiceSet
|
||||
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
||||
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
# Escape colons in extra_choices
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
choices = []
|
||||
for choice in self.initial['extra_choices']:
|
||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||
choices.append(choice)
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
def clean_extra_choices(self):
|
||||
data = []
|
||||
for line in self.cleaned_data['extra_choices'].splitlines():
|
||||
try:
|
||||
value, label = line.split(',', maxsplit=1)
|
||||
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||
value = value.replace('\\:', ':')
|
||||
label = label.replace('\\:', ':')
|
||||
except ValueError:
|
||||
value, label = line, line
|
||||
data.append((value, label))
|
||||
|
||||
@@ -114,7 +114,7 @@ class Command(BaseCommand):
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
object=module,
|
||||
name=script.name,
|
||||
name=script.class_name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.validators import RegexValidator, ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -57,6 +56,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
def get_defaults_for_model(self, model):
|
||||
"""
|
||||
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
|
||||
"""
|
||||
custom_fields = self.get_for_model(model).filter(default__isnull=False)
|
||||
return {
|
||||
cf.name: cf.default for cf in custom_fields
|
||||
}
|
||||
|
||||
|
||||
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
@@ -232,6 +240,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
return self.choice_set.choices
|
||||
return []
|
||||
|
||||
def get_choice_label(self, value):
|
||||
if not hasattr(self, '_choice_map'):
|
||||
self._choice_map = dict(self.choices)
|
||||
return self._choice_map.get(value, value)
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
@@ -557,8 +570,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
|
||||
# Multiselect
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
filter_class = filters.MultiValueCharFilter
|
||||
kwargs['lookup_expr'] = 'has_key'
|
||||
filter_class = filters.MultiValueArrayFilter
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
|
||||
@@ -315,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
text = clean_html(text, allowed_schemes)
|
||||
|
||||
# Sanitize link
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
|
||||
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
|
||||
|
||||
# Verify link scheme is allowed
|
||||
result = urllib.parse.urlparse(link)
|
||||
|
||||
@@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def get_module_and_report(module_name, report_name):
|
||||
module = ReportModule.objects.get(file_path=f'{module_name}.py')
|
||||
report = module.reports.get(report_name)
|
||||
report = module.reports.get(report_name)()
|
||||
return module, report
|
||||
|
||||
|
||||
@@ -106,8 +106,6 @@ class Report(object):
|
||||
'failure': 0,
|
||||
'log': [],
|
||||
}
|
||||
if not test_methods:
|
||||
raise Exception("A report must contain at least one test method.")
|
||||
self.test_methods = test_methods
|
||||
|
||||
@classproperty
|
||||
@@ -137,6 +135,13 @@ class Report(object):
|
||||
def source(self):
|
||||
return inspect.getsource(self.__class__)
|
||||
|
||||
@property
|
||||
def is_valid(self):
|
||||
"""
|
||||
Indicates whether the report can be run.
|
||||
"""
|
||||
return bool(self.test_methods)
|
||||
|
||||
#
|
||||
# Logging methods
|
||||
#
|
||||
|
||||
@@ -62,21 +62,20 @@ def handle_changed_object(sender, instance, **kwargs):
|
||||
else:
|
||||
return
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(instance),
|
||||
changed_object_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
# Record an ObjectChange
|
||||
if m2m_changed:
|
||||
ObjectChange.objects.filter(
|
||||
changed_object_type=ContentType.objects.get_for_model(instance),
|
||||
changed_object_id=instance.pk,
|
||||
request_id=request.id
|
||||
).update(
|
||||
postchange_data=instance.to_objectchange(action).postchange_data
|
||||
)
|
||||
else:
|
||||
objectchange = instance.to_objectchange(action)
|
||||
objectchange.user = request.user
|
||||
objectchange.request_id = request.id
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
queue = webhooks_queue.get()
|
||||
|
||||
265
netbox/extras/tests/test_custom_validation.py
Normal file
265
netbox/extras/tests/test_custom_validation.py
Normal file
@@ -0,0 +1,265 @@
|
||||
from django.test import TestCase
|
||||
from django.test import override_settings
|
||||
|
||||
from circuits.api.serializers import ProviderSerializer
|
||||
from circuits.forms import ProviderForm
|
||||
from circuits.models import Provider
|
||||
from ipam.models import ASN, RIR
|
||||
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
|
||||
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
|
||||
|
||||
|
||||
class ModelFormCustomValidationTest(TestCase):
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_tags_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for tag assignment.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
form = ProviderForm(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
tags = create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
data['tags'] = [tag.pk for tag in tags]
|
||||
form = ProviderForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_m2m_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for many-to-many fields.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
form = ProviderForm(data)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
data['asns'] = [asn.pk for asn in asns]
|
||||
form = ProviderForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
|
||||
class BulkEditCustomValidationTest(ModelViewTestCase):
|
||||
model = Provider
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
|
||||
providers = (
|
||||
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)
|
||||
for provider in providers:
|
||||
provider.asns.set(asns)
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_edit_without_m2m(self):
|
||||
"""
|
||||
Check that custom validation rules do not interfere with bulk editing.
|
||||
"""
|
||||
data = {
|
||||
'pk': list(Provider.objects.values_list('pk', flat=True)),
|
||||
'_apply': '',
|
||||
'description': 'New description',
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.change_provider',
|
||||
)
|
||||
|
||||
# Bulk edit the description without changing ASN assignments
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertEqual(
|
||||
Provider.objects.filter(description=data['description']).count(),
|
||||
len(data['pk'])
|
||||
)
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_edit_m2m(self):
|
||||
"""
|
||||
Test that custom validation rules are enforced during bulk editing.
|
||||
"""
|
||||
data = {
|
||||
'pk': list(Provider.objects.values_list('pk', flat=True)),
|
||||
'_apply': '',
|
||||
'description': 'New description',
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.change_provider',
|
||||
'ipam.view_asn',
|
||||
)
|
||||
|
||||
# Change the ASN assignments
|
||||
asn = ASN.objects.first()
|
||||
data['asns'] = [asn.pk]
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
for provider in Provider.objects.all():
|
||||
self.assertEqual(len(provider.asns.all()), 1)
|
||||
|
||||
# Attempt to remove the ASN assignments
|
||||
data.pop('asns')
|
||||
data['_nullify'] = 'asns'
|
||||
request = {
|
||||
'path': self._get_url('bulk_edit'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 200)
|
||||
for provider in Provider.objects.all():
|
||||
self.assertTrue(provider.asns.exists())
|
||||
|
||||
|
||||
class BulkImportCustomValidationTest(ModelViewTestCase):
|
||||
model = Provider
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_bulk_import_invalid(self):
|
||||
"""
|
||||
Test that custom validation rules are enforced during bulk import.
|
||||
"""
|
||||
csv_data = (
|
||||
"name,slug",
|
||||
"Provider 1,provider-1",
|
||||
"Provider 2,provider-2",
|
||||
"Provider 3,provider-3",
|
||||
)
|
||||
data = {
|
||||
'data': '\n'.join(csv_data),
|
||||
'format': ImportFormatChoices.CSV,
|
||||
'csv_delimiter': CSVDelimiterChoices.COMMA,
|
||||
}
|
||||
self.add_permissions(
|
||||
'circuits.view_provider',
|
||||
'circuits.add_provider',
|
||||
'extras.view_tag',
|
||||
)
|
||||
|
||||
# Attempt to import providers without tags
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 200)
|
||||
self.assertFalse(Provider.objects.exists())
|
||||
|
||||
# Import providers successfully with tag assignments
|
||||
csv_data = (
|
||||
"name,slug,tags",
|
||||
"Provider 1,provider-1,tag1",
|
||||
"Provider 2,provider-2,tag2",
|
||||
"Provider 3,provider-3,tag3",
|
||||
)
|
||||
data['data'] = '\n'.join(csv_data)
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
'data': post_data(data),
|
||||
}
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
self.assertTrue(Provider.objects.exists())
|
||||
|
||||
|
||||
class APISerializerCustomValidationTest(APITestCase):
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'tags': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_tags_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for tag assignment.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
|
||||
tags = create_tags('Tag1', 'Tag2', 'Tag3')
|
||||
data['tags'] = [tag.pk for tag in tags]
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
|
||||
@override_settings(CUSTOM_VALIDATORS={
|
||||
'circuits.provider': [
|
||||
{'asns': {'required': True}}
|
||||
]
|
||||
})
|
||||
def test_m2m_validation(self):
|
||||
"""
|
||||
Check that custom validation rules work for many-to-many fields.
|
||||
"""
|
||||
data = {
|
||||
'name': 'Provider 1',
|
||||
'slug': 'provider-1',
|
||||
}
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
|
||||
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
|
||||
asns = ASN.objects.bulk_create((
|
||||
ASN(rir=rir, asn=65001),
|
||||
ASN(rir=rir, asn=65002),
|
||||
ASN(rir=rir, asn=65003),
|
||||
))
|
||||
data['asns'] = [asn.pk for asn in asns]
|
||||
serializer = ProviderSerializer(data=data)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
|
||||
choice_set = CustomFieldChoiceSet.objects.create(
|
||||
name='Custom Field Choice Set 1',
|
||||
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
|
||||
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
|
||||
)
|
||||
|
||||
# Integer filtering
|
||||
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://a.example.com',
|
||||
'cf8': 'http://a.example.com',
|
||||
'cf9': 'A',
|
||||
'cf10': ['A', 'X'],
|
||||
'cf10': ['A', 'B'],
|
||||
'cf11': manufacturers[0].pk,
|
||||
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://b.example.com',
|
||||
'cf8': 'http://b.example.com',
|
||||
'cf9': 'B',
|
||||
'cf10': ['B', 'X'],
|
||||
'cf10': ['B', 'C'],
|
||||
'cf11': manufacturers[1].pk,
|
||||
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
'cf7': 'http://c.example.com',
|
||||
'cf8': 'http://c.example.com',
|
||||
'cf9': 'C',
|
||||
'cf10': ['C', 'X'],
|
||||
'cf10': None,
|
||||
'cf11': manufacturers[2].pk,
|
||||
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
|
||||
}),
|
||||
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
|
||||
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_multiselect(self):
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
|
||||
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_filter_object(self):
|
||||
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)
|
||||
|
||||
@@ -93,19 +93,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
name='Choice Set 3',
|
||||
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
|
||||
),
|
||||
CustomFieldChoiceSet(
|
||||
name='Choice Set 4',
|
||||
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
|
||||
),
|
||||
)
|
||||
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Choice Set X',
|
||||
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
|
||||
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
'name,extra_choices',
|
||||
'Choice Set 4,"D1,D2,D3"',
|
||||
'Choice Set 5,"E1,E2,E3"',
|
||||
'Choice Set 6,"F1,F2,F3"',
|
||||
'Choice Set 5,"D1,D2,D3"',
|
||||
'Choice Set 6,"E1,E2,E3"',
|
||||
'Choice Set 7,"F1,F2,F3"',
|
||||
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
@@ -113,12 +118,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
f'{choice_sets[0].pk},"A,B,C"',
|
||||
f'{choice_sets[1].pk},"A,B,C"',
|
||||
f'{choice_sets[2].pk},"A,B,C"',
|
||||
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
# This is here as extra_choices field splits on colon, but is returned
|
||||
# from DB as comma separated.
|
||||
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||
if 'extra_choices' in data:
|
||||
data['extra_choices'] = data['extra_choices'].replace(':', ',')
|
||||
return super().assertInstanceEqual(instance, data, exclude, api)
|
||||
|
||||
|
||||
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = CustomLink
|
||||
@@ -457,7 +470,7 @@ class ConfigContextTestCase(
|
||||
'platforms': [],
|
||||
'tenant_groups': [],
|
||||
'tenants': [],
|
||||
'device_types': [devicetype.id,],
|
||||
'device_types': [devicetype.id],
|
||||
'tags': [],
|
||||
'data': '{"foo": 123}',
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# NOTE: As this module may be imported by configuration.py, we cannot import
|
||||
# anything from NetBox itself.
|
||||
@@ -66,8 +67,7 @@ class CustomValidator:
|
||||
def __call__(self, instance):
|
||||
# Validate instance attributes per validation rules
|
||||
for attr_name, rules in self.validation_rules.items():
|
||||
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
|
||||
attr = getattr(instance, attr_name)
|
||||
attr = self._getattr(instance, attr_name)
|
||||
for descriptor, value in rules.items():
|
||||
validator = self.get_validator(descriptor, value)
|
||||
try:
|
||||
@@ -79,6 +79,26 @@ class CustomValidator:
|
||||
# Execute custom validation logic (if any)
|
||||
self.validate(instance)
|
||||
|
||||
@staticmethod
|
||||
def _getattr(instance, name):
|
||||
# Attempt to resolve many-to-many fields to their stored values
|
||||
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
|
||||
if name in m2m_fields:
|
||||
if name in getattr(instance, '_m2m_values', []):
|
||||
return instance._m2m_values[name]
|
||||
if instance.pk:
|
||||
return list(getattr(instance, name).all())
|
||||
return []
|
||||
|
||||
# Raise a ValidationError for unknown attributes
|
||||
if not hasattr(instance, name):
|
||||
raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
|
||||
name=name,
|
||||
model=instance.__class__.__name__
|
||||
))
|
||||
|
||||
return getattr(instance, name)
|
||||
|
||||
def get_validator(self, descriptor, value):
|
||||
"""
|
||||
Instantiate and return the appropriate validator based on the descriptor given. For
|
||||
|
||||
@@ -978,6 +978,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
def get_report_module(module, request):
|
||||
return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
|
||||
|
||||
class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a single Report and its associated Job (if any).
|
||||
@@ -986,7 +990,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
@@ -1007,7 +1011,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
if not request.user.has_perm('extras.run_report'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
|
||||
|
||||
@@ -1046,7 +1050,7 @@ class ReportSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
return render(request, 'extras/report/source.html', {
|
||||
@@ -1062,14 +1066,14 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ReportModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_report_module(module, request)
|
||||
report = module.reports[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
|
||||
jobs = Job.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id=module.pk,
|
||||
name=report.name
|
||||
name=report.class_name
|
||||
)
|
||||
|
||||
jobs_table = JobTable(
|
||||
@@ -1151,13 +1155,17 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
def get_script_module(module, request):
|
||||
return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
|
||||
|
||||
|
||||
class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
@@ -1181,7 +1189,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
|
||||
@@ -1218,7 +1226,7 @@ class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
|
||||
return render(request, 'extras/script/source.html', {
|
||||
@@ -1234,7 +1242,7 @@ class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
module = get_script_module(module, request)
|
||||
script = module.scripts[name]()
|
||||
|
||||
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
|
||||
|
||||
@@ -115,7 +115,7 @@ def flush_webhooks(queue):
|
||||
event=data['event'],
|
||||
data=data['data'],
|
||||
snapshots=data['snapshots'],
|
||||
timestamp=str(timezone.now()),
|
||||
timestamp=timezone.now().isoformat(),
|
||||
username=data['username'],
|
||||
request_id=data['request_id'],
|
||||
retry=get_rq_retry()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from copy import deepcopy
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
@@ -266,6 +268,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
|
||||
# Normalize request data to a list of objects
|
||||
requested_objects = request.data if isinstance(request.data, list) else [request.data]
|
||||
limit = len(requested_objects)
|
||||
|
||||
# Serialize and validate the request data
|
||||
serializer = self.write_serializer_class(data=requested_objects, many=True, context={
|
||||
@@ -279,7 +282,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]):
|
||||
available_objects = self.get_available_objects(parent)
|
||||
available_objects = self.get_available_objects(parent, limit)
|
||||
|
||||
# Determine if the requested number of objects is available
|
||||
if not self.check_sufficient_available(serializer.validated_data, available_objects):
|
||||
@@ -289,7 +292,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
)
|
||||
|
||||
# Prepare object data for deserialization
|
||||
requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent)
|
||||
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||
|
||||
@@ -29,6 +29,7 @@ __all__ = (
|
||||
'L2VPNFilterSet',
|
||||
'L2VPNTerminationFilterSet',
|
||||
'PrefixFilterSet',
|
||||
'PrimaryIPFilterSet',
|
||||
'RIRFilterSet',
|
||||
'RoleFilterSet',
|
||||
'RouteTargetFilterSet',
|
||||
@@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
)
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='prefix',
|
||||
lookup_expr='net_mask_length'
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='prefix',
|
||||
@@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='filter_address',
|
||||
label=_('Address'),
|
||||
)
|
||||
mask_length = django_filters.NumberFilter(
|
||||
method='filter_mask_length',
|
||||
label=_('Mask length'),
|
||||
mask_length = MultiValueNumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length',
|
||||
label=_('Mask length')
|
||||
)
|
||||
mask_length__gte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__gte'
|
||||
)
|
||||
mask_length__lte = django_filters.NumberFilter(
|
||||
field_name='address',
|
||||
lookup_expr='net_mask_length__lte'
|
||||
)
|
||||
vrf_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
except ValidationError:
|
||||
return queryset.none()
|
||||
|
||||
def filter_mask_length(self, queryset, name, value):
|
||||
if not value:
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
@@ -944,6 +950,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
choices=VLANStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
available_at_site = django_filters.ModelChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
method='get_for_site'
|
||||
)
|
||||
available_on_device = django_filters.ModelChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
method='get_for_device'
|
||||
@@ -978,6 +988,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_site(self, queryset, name, value):
|
||||
return queryset.get_for_site(value)
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def get_for_device(self, queryset, name, value):
|
||||
return queryset.get_for_device(value)
|
||||
@@ -1227,3 +1241,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
|
||||
)
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
class PrimaryIPFilterSet(django_filters.FilterSet):
|
||||
"""
|
||||
An inheritable FilterSet for models which support primary IP assignment.
|
||||
"""
|
||||
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)'),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
@@ -10,9 +11,10 @@ from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import (
|
||||
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
)
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
|
||||
__all__ = (
|
||||
'AggregateBulkEditForm',
|
||||
@@ -407,11 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
|
||||
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
)
|
||||
min_vid = forms.IntegerField(
|
||||
min_value=VLAN_VID_MIN,
|
||||
max_value=VLAN_VID_MAX,
|
||||
@@ -429,12 +426,84 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
scope_type = ContentTypeChoiceField(
|
||||
label=_('Scope type'),
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False
|
||||
)
|
||||
scope_id = forms.IntegerField(
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$sitegroup',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
label=_('Location'),
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
label=_('Rack'),
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
clustergroup = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster group')
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$clustergroup',
|
||||
}
|
||||
)
|
||||
|
||||
model = VLANGroup
|
||||
fieldsets = (
|
||||
(None, ('site', 'min_vid', 'max_vid', 'description')),
|
||||
(_('Scope'), ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')),
|
||||
)
|
||||
nullable_fields = ('site', 'description')
|
||||
nullable_fields = ('description',)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Assign scope based on scope_type
|
||||
if self.cleaned_data.get('scope_type'):
|
||||
scope_field = self.cleaned_data['scope_type'].model
|
||||
if scope_obj := self.cleaned_data.get(scope_field):
|
||||
self.cleaned_data['scope_id'] = scope_obj.pk
|
||||
self.changed_data.append('scope_id')
|
||||
else:
|
||||
self.cleaned_data.pop('scope_type')
|
||||
self.changed_data.remove('scope_type')
|
||||
|
||||
|
||||
class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
@@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
|
||||
choices=ServiceProtocolChoices,
|
||||
help_text=_('IP protocol')
|
||||
)
|
||||
ipaddresses = CSVModelMultipleChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
to_field_name='address',
|
||||
help_text=_('IP Address'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def clean_ipaddresses(self):
|
||||
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
|
||||
for ip_address in self.cleaned_data['ipaddresses']:
|
||||
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
||||
raise forms.ValidationError(
|
||||
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
|
||||
)
|
||||
|
||||
return self.cleaned_data['ipaddresses']
|
||||
|
||||
|
||||
class L2VPNImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -295,11 +295,12 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = IPAddress
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
|
||||
(_('Attributes'), ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface', 'dns_name')),
|
||||
(_('VRF'), ('vrf_id', 'present_in_vrf_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -357,6 +358,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
dns_name = forms.CharField(
|
||||
required=False,
|
||||
label=_('DNS Name')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -448,6 +453,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'site_id')
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -519,6 +525,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
(_('Attributes'), ('protocol', 'port')),
|
||||
(_('Assignment'), ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Machine'),
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
})
|
||||
elif selected_objects:
|
||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
||||
)
|
||||
@@ -818,7 +818,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
|
||||
class Meta:
|
||||
model = L2VPNTermination
|
||||
fields = ('l2vpn', )
|
||||
fields = ('l2vpn', 'tags')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
instance = kwargs.get('instance')
|
||||
|
||||
@@ -782,6 +782,13 @@ class IPAddress(PrimaryModel):
|
||||
def __str__(self):
|
||||
return str(self.address)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Denote the original assigned object (if any) for validation in clean()
|
||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:ipaddress', args=[self.pk])
|
||||
|
||||
@@ -843,6 +850,26 @@ class IPAddress(PrimaryModel):
|
||||
)
|
||||
})
|
||||
|
||||
if self._original_assigned_object_id and self._original_assigned_object_type_id:
|
||||
parent = getattr(self.assigned_object, 'parent_object', None)
|
||||
ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id)
|
||||
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
|
||||
original_parent = getattr(original_assigned_object, 'parent_object', None)
|
||||
|
||||
# can't use is_primary_ip as self.assigned_object might be changed
|
||||
is_primary = False
|
||||
if self.family == 4 and hasattr(original_parent, 'primary_ip4') and original_parent.primary_ip4_id == self.pk:
|
||||
is_primary = True
|
||||
if self.family == 6 and hasattr(original_parent, 'primary_ip6') and original_parent.primary_ip6_id == self.pk:
|
||||
is_primary = True
|
||||
|
||||
if is_primary and (parent != original_parent):
|
||||
raise ValidationError({
|
||||
'assigned_object': _(
|
||||
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
|
||||
)
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
raise ValidationError({
|
||||
|
||||
@@ -224,11 +224,11 @@ class VLAN(PrimaryModel):
|
||||
|
||||
# Validate VLAN group (if assigned)
|
||||
if self.group and self.site and self.group.scope != self.site:
|
||||
raise ValidationError({
|
||||
'group': _(
|
||||
raise ValidationError(
|
||||
_(
|
||||
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
|
||||
).format(group=self.group, scope=self.group.scope, site=self.site)
|
||||
})
|
||||
)
|
||||
|
||||
# Validate group min/max VIDs
|
||||
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
|
||||
|
||||
@@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet):
|
||||
|
||||
class VLANQuerySet(RestrictedQuerySet):
|
||||
|
||||
def get_for_site(self, site):
|
||||
"""
|
||||
Return all VLANs in the specified site
|
||||
"""
|
||||
from .models import VLANGroup
|
||||
q = Q()
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
|
||||
scope_id=site.pk
|
||||
)
|
||||
|
||||
if site.region:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
|
||||
scope_id__in=site.region.get_ancestors(include_self=True)
|
||||
)
|
||||
if site.group:
|
||||
q |= Q(
|
||||
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
|
||||
scope_id__in=site.group.get_ancestors(include_self=True)
|
||||
)
|
||||
|
||||
return self.filter(
|
||||
Q(group__in=VLANGroup.objects.filter(q)) |
|
||||
Q(site=site) |
|
||||
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
|
||||
Q(group__isnull=True, site__isnull=True) # Global VLANs
|
||||
)
|
||||
|
||||
def get_for_device(self, device):
|
||||
"""
|
||||
Return all VLANs available to the specified Device.
|
||||
|
||||
@@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs):
|
||||
"""
|
||||
field_name = f'primary_ip{instance.family}'
|
||||
if device := Device.objects.filter(**{field_name: instance}).first():
|
||||
device.snapshot()
|
||||
setattr(device, field_name, None)
|
||||
device.save()
|
||||
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
|
||||
virtualmachine.snapshot()
|
||||
setattr(virtualmachine, field_name, None)
|
||||
virtualmachine.save()
|
||||
|
||||
|
||||
@@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs):
|
||||
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
|
||||
"""
|
||||
if device := Device.objects.filter(oob_ip=instance).first():
|
||||
device.snapshot()
|
||||
device.oob_ip = None
|
||||
device.save()
|
||||
|
||||
@@ -48,6 +48,7 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn_asdot = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True,
|
||||
order_by=tables.A('asn'),
|
||||
verbose_name=_('ASDOT')
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
|
||||
@@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name=_('Object Site')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:l2vpntermination_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = L2VPNTermination
|
||||
fields = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
|
||||
'actions',
|
||||
'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
|
||||
|
||||
@@ -659,6 +659,62 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
def test_assign_object(self):
|
||||
"""
|
||||
Test the creation of available IP addresses within a parent IP range.
|
||||
"""
|
||||
site = Site.objects.create(name='Site 1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
|
||||
device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
|
||||
role = DeviceRole.objects.create(name='Switch')
|
||||
device1 = Device.objects.create(
|
||||
name='Device 1',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
interface1 = Interface.objects.create(name='Interface 1', device=device1, type='1000baset')
|
||||
interface2 = Interface.objects.create(name='Interface 2', device=device1, type='1000baset')
|
||||
device2 = Device.objects.create(
|
||||
name='Device 2',
|
||||
site=site,
|
||||
device_type=device_type,
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
interface3 = Interface.objects.create(name='Interface 3', device=device2, type='1000baset')
|
||||
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.4/24'), assigned_object=interface1),
|
||||
IPAddress(address=IPNetwork('192.168.1.4/24')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
ip1 = ip_addresses[0]
|
||||
ip1.assigned_object = interface1
|
||||
device1.primary_ip4 = ip_addresses[0]
|
||||
device1.save()
|
||||
|
||||
ip2 = ip_addresses[1]
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': ip1.pk})
|
||||
self.add_permissions('ipam.change_ipaddress')
|
||||
|
||||
# assign to same parent
|
||||
data = {
|
||||
'assigned_object_id': interface2.pk
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
# assign to same different parent - should error
|
||||
data = {
|
||||
'assigned_object_id': interface3.pk
|
||||
}
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FHRPGroup
|
||||
|
||||
@@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': ['24']}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'mask_length__gte': 32}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__lte': 24}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
@@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
params = {'mask_length': [24]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
params = {'mask_length__gte': 64}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'mask_length__lte': 25}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_vrf(self):
|
||||
vrfs = VRF.objects.all()[:2]
|
||||
@@ -1351,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
|
||||
VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
|
||||
VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
|
||||
)
|
||||
VLANGroup.objects.bulk_create(groups)
|
||||
|
||||
@@ -1407,6 +1416,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
|
||||
VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
|
||||
|
||||
# Create one globally available VLAN on a VLAN group
|
||||
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
|
||||
|
||||
# Create one globally available VLAN
|
||||
VLAN(vid=1000, name='Global VLAN'),
|
||||
)
|
||||
@@ -1480,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_available_on_device(self):
|
||||
device_id = Device.objects.first().pk
|
||||
params = {'available_on_device': device_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
||||
|
||||
def test_available_on_virtualmachine(self):
|
||||
vm_id = VirtualMachine.objects.first().pk
|
||||
params = {'available_on_virtualmachine': vm_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
|
||||
|
||||
def test_available_at_site(self):
|
||||
site_id = Site.objects.first().pk
|
||||
params = {'available_at_site': site_id}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global
|
||||
|
||||
|
||||
class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.constants import InterfaceTypeChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
|
||||
from ipam.choices import *
|
||||
from ipam.models import *
|
||||
@@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
|
||||
services = (
|
||||
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||
@@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
)
|
||||
Service.objects.bulk_create(services)
|
||||
|
||||
ip_addresses = (
|
||||
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
@@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name,protocol,ports,description",
|
||||
"Device 1,Service 1,tcp,1,First service",
|
||||
"Device 1,Service 2,tcp,2,Second service",
|
||||
"Device 1,Service 3,udp,3,Third service",
|
||||
"device,name,protocol,ports,ipaddresses,description",
|
||||
"Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||
"Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||
"Device 1,Service 3,udp,3,,Third service",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
|
||||
tab = ViewTab(
|
||||
label=_('ASNs'),
|
||||
badge=lambda x: x.get_child_asns().count(),
|
||||
permission='ipam.view_asns',
|
||||
permission='ipam.view_asn',
|
||||
weight=500
|
||||
)
|
||||
|
||||
@@ -661,6 +661,26 @@ class IPRangeListView(generic.ObjectListView):
|
||||
class IPRangeView(generic.ObjectView):
|
||||
queryset = IPRange.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(prefix__net_contains_or_equals=str(instance.start_address.ip)),
|
||||
Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
|
||||
vrf=instance.vrf
|
||||
).prefetch_related(
|
||||
'site', 'role', 'tenant', 'vlan', 'role'
|
||||
)
|
||||
parent_prefixes_table = tables.PrefixTable(
|
||||
list(parent_prefixes),
|
||||
exclude=('vrf', 'utilization'),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
|
||||
class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
@@ -953,7 +973,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
if not get_table_ordering(request, self.table):
|
||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
||||
return add_available_vlans(queryset, parent)
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -46,12 +46,13 @@ class ChoiceField(serializers.Field):
|
||||
return super().validate_empty_values(data)
|
||||
|
||||
def to_representation(self, obj):
|
||||
if obj == '':
|
||||
return None
|
||||
return {
|
||||
'value': obj,
|
||||
'label': self._choices[obj],
|
||||
}
|
||||
if obj != '':
|
||||
# Use an empty string in place of the choice label if it cannot be resolved (i.e. because a previously
|
||||
# configured choice has been removed from FIELD_CHOICES).
|
||||
return {
|
||||
'value': obj,
|
||||
'label': self._choices.get(obj, ''),
|
||||
}
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if data == '':
|
||||
|
||||
@@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
def validate(self, data):
|
||||
|
||||
# Remove custom fields data and tags (if any) prior to model validation
|
||||
attrs = data.copy()
|
||||
|
||||
# Remove custom field data (if any) prior to model validation
|
||||
attrs.pop('custom_fields', None)
|
||||
attrs.pop('tags', None)
|
||||
|
||||
# Skip ManyToManyFields
|
||||
for field in self.Meta.model._meta.get_fields():
|
||||
if isinstance(field, ManyToManyField):
|
||||
attrs.pop(field.name, None)
|
||||
m2m_values = {}
|
||||
for field in self.Meta.model._meta.local_many_to_many:
|
||||
if field.name in attrs:
|
||||
m2m_values[field.name] = attrs.pop(field.name)
|
||||
|
||||
# Run clean() on an instance of the model
|
||||
if self.instance is None:
|
||||
@@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
instance = self.instance
|
||||
for k, v in attrs.items():
|
||||
setattr(instance, k, v)
|
||||
instance._m2m_values = m2m_values
|
||||
instance.full_clean()
|
||||
|
||||
return data
|
||||
|
||||
@@ -3,6 +3,8 @@ import logging
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db.models import ProtectedError
|
||||
from django_pglocks import advisory_lock
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from rest_framework import mixins as drf_mixins
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
@@ -157,3 +159,22 @@ class NetBoxModelViewSet(
|
||||
logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
|
||||
|
||||
return super().perform_destroy(instance)
|
||||
|
||||
|
||||
class MPTTLockedMixin:
|
||||
"""
|
||||
Puts pglock on objects that derive from MPTTModel for parallel API calling.
|
||||
Note: If adding this to a view, must add the model name to ADVISORY_LOCK_KEYS
|
||||
"""
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
with advisory_lock(ADVISORY_LOCK_KEYS[self.queryset.model._meta.model_name]):
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
@@ -56,8 +56,15 @@ class BriefModeMixin:
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
|
||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
if self.brief:
|
||||
serializer_class = self.get_serializer_class()
|
||||
|
||||
# Clear any annotations for fields not present on the nested serializer
|
||||
for annotation in list(qs.query.annotations.keys()):
|
||||
if annotation not in serializer_class().fields:
|
||||
qs.query.annotations.pop(annotation)
|
||||
|
||||
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||
|
||||
return qs
|
||||
|
||||
@@ -11,8 +11,19 @@ RQ_QUEUE_LOW = 'low'
|
||||
# When adding a new key, pick something arbitrary and unique so that it is easily searchable in
|
||||
# query logs.
|
||||
ADVISORY_LOCK_KEYS = {
|
||||
# Available object locks
|
||||
'available-prefixes': 100100,
|
||||
'available-ips': 100200,
|
||||
'available-vlans': 100300,
|
||||
'available-asns': 100400,
|
||||
|
||||
# MPTT locks
|
||||
'region': 105100,
|
||||
'sitegroup': 105200,
|
||||
'location': 105300,
|
||||
'tenantgroup': 105400,
|
||||
'contactgroup': 105500,
|
||||
'wirelesslangroup': 105600,
|
||||
'inventoryitem': 105700,
|
||||
'inventoryitemtemplate': 105800,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
|
||||
|
||||
return super().clean()
|
||||
|
||||
def _post_clean(self):
|
||||
"""
|
||||
Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
|
||||
"""
|
||||
self.instance._m2m_values = {}
|
||||
for field in self.instance._meta.local_many_to_many:
|
||||
if field.name in self.cleaned_data:
|
||||
self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
|
||||
|
||||
return super()._post_clean()
|
||||
|
||||
|
||||
class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
"""
|
||||
@@ -145,12 +156,16 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi
|
||||
model: The model class associated with the form
|
||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||
a selector widget
|
||||
"""
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -85,11 +86,16 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
|
||||
|
||||
if ct_value and fk_value:
|
||||
klass = getattr(self, field.ct_field).model_class()
|
||||
if not klass.objects.filter(pk=fk_value).exists():
|
||||
try:
|
||||
obj = klass.objects.get(pk=fk_value)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError({
|
||||
field.fk_field: f"Related object not found using the provided value: {fk_value}."
|
||||
})
|
||||
|
||||
# update the GFK field value
|
||||
setattr(self, field.name, obj)
|
||||
|
||||
|
||||
#
|
||||
# NetBox internal base models
|
||||
|
||||
@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.6.2'
|
||||
VERSION = '3.6.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -95,6 +95,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
|
||||
CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False)
|
||||
CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440)
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
@@ -355,6 +356,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'django.forms',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'graphiql_debug_toolbar',
|
||||
@@ -430,6 +432,9 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
# This allows us to override Django's stock form widget templates
|
||||
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
|
||||
|
||||
# Set up authentication backends
|
||||
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
|
||||
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
|
||||
@@ -497,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
|
||||
MAINTENANCE_EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}admin/',
|
||||
f'/{BASE_PATH}extras/config-revisions/', # Allow modifying the configuration
|
||||
LOGIN_URL,
|
||||
LOGIN_REDIRECT_URL,
|
||||
LOGOUT_REDIRECT_URL
|
||||
)
|
||||
|
||||
SERIALIZATION_MODULES = {
|
||||
|
||||
@@ -483,8 +483,10 @@ class CustomFieldColumn(tables.Column):
|
||||
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||
return self.customfield.get_choice_label(value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
return ', '.join(v for v in value)
|
||||
return ', '.join(self.customfield.get_choice_label(v) for v in value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
return mark_safe(', '.join(
|
||||
self._linkify_item(obj) for obj in self.customfield.deserialize(value)
|
||||
|
||||
@@ -119,7 +119,7 @@ class BaseTable(tables.Table):
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
return sorted(self._get_columns(visible=False))
|
||||
|
||||
@property
|
||||
def selected_columns(self):
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.fields import GenericRel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
@@ -393,6 +394,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
|
||||
raise ValidationError('')
|
||||
|
||||
# Take a snapshot for change logging
|
||||
if instance.pk and hasattr(instance, 'snapshot'):
|
||||
instance.snapshot()
|
||||
|
||||
# Instantiate the model form for the object
|
||||
model_form_kwargs = {
|
||||
'data': record,
|
||||
@@ -519,9 +524,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
model_field = self.queryset.model._meta.get_field(name)
|
||||
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
m2m_fields[name] = model_field
|
||||
elif isinstance(model_field, GenericRel):
|
||||
# Ignore generic relations (these may be used for other purposes in the form)
|
||||
continue
|
||||
else:
|
||||
model_fields[name] = model_field
|
||||
|
||||
except FieldDoesNotExist:
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_fields[name] = None
|
||||
@@ -550,6 +557,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
elif name in form.changed_data:
|
||||
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
|
||||
|
||||
# Store M2M values for validation
|
||||
obj._m2m_values = {}
|
||||
for field in obj._meta.local_many_to_many:
|
||||
if value := form.cleaned_data.get(field.name):
|
||||
obj._m2m_values[field.name] = list(value)
|
||||
elif field.name in nullified_fields:
|
||||
obj._m2m_values[field.name] = []
|
||||
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
updated_objects.append(obj)
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
18
netbox/project-static/dist/netbox.js
vendored
18
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox.js.map
vendored
2
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
|
||||
import { getElements } from './util';
|
||||
|
||||
export function initClipboard(): void {
|
||||
for (const element of getElements('a.copy-content')) {
|
||||
for (const element of getElements('.copy-content')) {
|
||||
new Clipboard(element);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ const showHideLayout: ShowHideLayout = {
|
||||
const showHideMap: ShowHideMap = {
|
||||
vlangroup_add: 'vlangroup',
|
||||
vlangroup_edit: 'vlangroup',
|
||||
vlangroup_bulk_edit: 'vlangroup',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -264,6 +264,11 @@ export class APISelect {
|
||||
switch (this.trigger) {
|
||||
case 'collapse':
|
||||
if (collapse !== null) {
|
||||
// If the element is collapsible but already shown, load the data immediately.
|
||||
if (collapse.classList.contains('show')) {
|
||||
Promise.all([this.loadData()]);
|
||||
}
|
||||
|
||||
// If this element is part of a collapsible element, only load the data when the
|
||||
// collapsible element is shown.
|
||||
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
||||
|
||||
@@ -141,9 +141,10 @@ class TableState {
|
||||
private virtualButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Underlying DOM Table Caption Element.
|
||||
* Instance of ButtonState for the 'show/hide virtual rows' button.
|
||||
*/
|
||||
private caption: Nullable<HTMLTableCaptionElement> = null;
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private disconnectedButton: ButtonState;
|
||||
|
||||
/**
|
||||
* All table rows in table
|
||||
@@ -166,9 +167,10 @@ class TableState {
|
||||
this.table,
|
||||
'button.toggle-virtual',
|
||||
);
|
||||
|
||||
const caption = this.table.querySelector('caption');
|
||||
this.caption = caption;
|
||||
const toggleDisconnectedButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
'button.toggle-disconnected',
|
||||
);
|
||||
|
||||
if (toggleEnabledButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-enabled' button.", table);
|
||||
@@ -182,10 +184,15 @@ class TableState {
|
||||
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
|
||||
}
|
||||
|
||||
if (toggleDisconnectedButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-disconnected' button.", table);
|
||||
}
|
||||
|
||||
// Attach event listeners to the buttons elements.
|
||||
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisconnectedButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
|
||||
// Instantiate ButtonState for each button for state management.
|
||||
this.enabledButton = new ButtonState(
|
||||
@@ -200,6 +207,10 @@ class TableState {
|
||||
toggleVirtualButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
|
||||
);
|
||||
this.disconnectedButton = new ButtonState(
|
||||
toggleDisconnectedButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-connected="disconnected"]'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TableStateError) {
|
||||
// This class is useless for tables that don't have toggle buttons.
|
||||
@@ -211,52 +222,6 @@ class TableState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table caption's text.
|
||||
*/
|
||||
private get captionText(): string {
|
||||
if (this.caption !== null) {
|
||||
return this.caption.innerText;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the table caption's text.
|
||||
*/
|
||||
private set captionText(value: string) {
|
||||
if (this.caption !== null) {
|
||||
this.caption.innerText = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the table caption's text based on the state of each toggle button.
|
||||
*/
|
||||
private toggleCaption(): void {
|
||||
const showEnabled = this.enabledButton.buttonState === 'show';
|
||||
const showDisabled = this.disabledButton.buttonState === 'show';
|
||||
const showVirtual = this.virtualButton.buttonState === 'show';
|
||||
|
||||
if (showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled Interfaces';
|
||||
} else if (showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Virtual Interfaces';
|
||||
} else if (showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Virtual Interfaces';
|
||||
} else if (showEnabled && showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
|
||||
} else {
|
||||
this.captionText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When toggle buttons are clicked, reapply visability all rows and
|
||||
* pass the event to all button handlers
|
||||
@@ -272,7 +237,7 @@ class TableState {
|
||||
instance.enabledButton.handleClick(event);
|
||||
instance.disabledButton.handleClick(event);
|
||||
instance.virtualButton.handleClick(event);
|
||||
instance.toggleCaption();
|
||||
instance.disconnectedButton.handleClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,12 @@ table td > .progress {
|
||||
}
|
||||
}
|
||||
|
||||
.alert {
|
||||
code {
|
||||
color: $gray-600;
|
||||
}
|
||||
}
|
||||
|
||||
span.profile-button .dropdown-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
|
||||
@@ -282,7 +282,7 @@ $btn-close-white-filter: invert(1) grayscale(100%) brightness(200%);
|
||||
$btn-close-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='#{$btn-close-color}'><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>");
|
||||
|
||||
// Code
|
||||
$code-color: $gray-600;
|
||||
$code-color: $gray-200;
|
||||
$kbd-color: $white;
|
||||
$kbd-bg: $gray-300;
|
||||
$pre-color: null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user