Compare commits

..

79 Commits

Author SHA1 Message Date
Jeremy Stretch
28080e9b14 Merge pull request #14386 from netbox-community/develop
Release v3.6.6
2023-11-29 19:30:47 -05:00
Jeremy Stretch
04fd45581d Release v3.6.6 2023-11-29 19:16:30 -05:00
Jeremy Stretch
0a8eb7fcbe Update changelog 2023-11-29 17:25:10 -05:00
Jeremy Stretch
ac3fc25dfd Fixes #14239: Fix CustomFieldChoiceSet search filter 2023-11-29 17:20:18 -05:00
Jeremy Stretch
82591ad8a1 Fixes #14056: Record a pre-change snapshot when bulk editing objects via CSV 2023-11-29 17:19:35 -05:00
Jeremy Stretch
6dddb6c9d2 Fixes #14199: Fix jobs count for reports with a custom name 2023-11-29 17:19:02 -05:00
Abhimanyu Saharan
290aae592d Raises validation error if file path and root are not unique (#14232)
* raises validation error if file path and root are not unique #14187

* review changes #14187
2023-11-29 16:25:16 -05:00
Abhimanyu Saharan
ff021a8e4e Adds region hierarchy in templates (#14213)
* initial work to render hierarchical region #13735

* adds site display #13735

* cleanup #13735

* adds display region tag #13735

* refactored region hierarchy #13735

* refactored region hierarchy #13735

* renamed display_region to nested_tree #13735

* Make render_tree suitable for generic use

* Remove errant item from __all__

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-29 16:20:59 -05:00
Vincent Simonin
3a3d43911c Fixed password was not hashed on REST API update (#14340)
* Fixed password was not hashed on REST API update

* When we updated a user password with a REST API call the password was
  stored in clear in plain text in the database.

* Following code review

* Move test on UserTest class
* Call `super().update()` in overriding `update` method

* Return directly the result of `super().update()`
2023-11-29 15:59:54 -05:00
Josef Johansson
c43c63a817 14346 fix missing function call convert
In PR #13958 (commit 8224644) _get_report was modified to do the call on the variable without changing the call later on.

This commit fixes that and removes the call on the variable.

Signed-off-by: Josef Johansson <josef@oderland.se>
2023-11-29 15:58:14 -05:00
Jeremy Stretch
792b353f64 Fixes #14363: Fix bulk editing of interfaces assigned to VM with no cluster 2023-11-29 15:23:35 -05:00
Jeremy Stretch
01ba4ce129 Fixes #14242: Enable export templates for contact assignments 2023-11-29 15:22:41 -05:00
Jeremy Stretch
fc7d6e1387 Fixes #14325: Ensure expanded numeric arrays are ordered (#14370)
* Fixes #14325: Ensure expanded numeric arrays are ordered

* Remove redundant casting to
2023-11-28 17:04:10 -05:00
Jeremy Stretch
080da68b6a Fixes #14349: Fix custom validation support for DataSource 2023-11-28 17:02:52 -05:00
Jeremy Stretch
7d413ea3c2 Fixes #14343: Set order_by accessor for asn_asdot column (#14369) 2023-11-28 17:02:07 -05:00
Arthur Hanson
40763b58bd 14299 change webhook timestamp to isoformat (#14331)
* 14299 change timestamp to isoformat

* Omit redundant str() casting

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-28 17:01:03 -05:00
Jeremy Stretch
d52a6d3b10 PRVB 2023-11-09 16:04:38 -05:00
Jeremy Stretch
6ac25eeb65 Merge pull request #14238 from netbox-community/develop
Release v3.6.5
2023-11-09 16:00:56 -05:00
Jeremy Stretch
41eae1bc19 Release v3.6.5 2023-11-09 15:45:49 -05:00
Jeremy Stretch
351aaf8397 Changelog for #12741, #13022, #13587, #13936, #14085, #14117, #14166, #14182, #14195, #14221 2023-11-09 15:20:24 -05:00
Abhimanyu Saharan
5c27d29b08 Adds unit to the power port draw (#14208)
* adds unit to the power port draw #13587

* review changes #13587

* moved units to header #13587

* Abbreviate unit for consistency with e.g. PowerFeedTable available_power column

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-09 15:09:16 -05:00
Abhimanyu Saharan
e1bedb8350 restores config revision during cache clear #14182 2023-11-09 14:50:45 -05:00
Abhimanyu Saharan
dd5e20aa1a allow login and logout in maintenance mode #14166 2023-11-09 14:45:47 -05:00
Abhimanyu Saharan
217a9edb4c handles the port in the ip #14085 2023-11-09 14:43:36 -05:00
Abhimanyu Saharan
ad95760ead adds contact group on contact assignment table #14221 2023-11-09 14:12:10 -05:00
Abhimanyu Saharan
57bf2a2f00 fix asn view under asn range #14195 2023-11-09 10:58:28 -05:00
Jeremy Stretch
e5c38e0829 Closes #13022: Add IP assignment support when bulk importing services (#14230)
* issue 13022 resolved, ipaddress added into bulk_import form

* validation of ip address for device and virtual machine

* error message modified

* error message modified

* error message modified

* Fix form validation

* Extend bulk import test

---------

Co-authored-by: yash-pal1 <ypal@onemindservices.com>
Co-authored-by: yash-pal1 <ypal@onemindservies.com>
2023-11-09 10:55:55 -05:00
Artem Kotik
6b89da2233 Closes #13936: Add primary_ip4 and primary_ip6 filters to VirtualMachine and VirtualDeviceContext filtersets (#14203)
* Add primary_ip4 and primary_ip6 filters for VirtualMachine and VirtualDeviceContext filtersets (#13936)

* Add PrimaryIPFilterSet to __all__

---------

Co-authored-by: Artem I. Kotik <artem.i.kotik@ringcentral.com>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-09 09:56:43 -05:00
Prince Kumar
092f2b06ab Enhance Virtual Machine and Device Platform Filter with Manufacturer Information (#14047)
* Add manufacturer for filters in the virtual machine and device #12741

* reverse the filtersets of device and vm

* revert the filtersets of vm

* add advance selector in platform

* remove manufacture from imports
2023-11-09 09:55:44 -05:00
Jeremy Stretch
6900097e2d Fixes #14117: Validate the number of front ports to be created 2023-11-09 09:50:54 -05:00
Jeremy Stretch
5000564430 Changelog for #13669, #13723, #13743, #13951, #14033, #14101, #14112, #14113, #14220, #14220 2023-11-09 09:19:49 -05:00
Abhimanyu Saharan
95519b42a0 Adds device and vm to service filter form (#14215)
* adds device and vm to service filter form #13951

* Tweak labels

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-09 09:13:46 -05:00
Chris Mills
dfef89ab88 Fix ordering on JobTable. #14223 2023-11-09 08:50:15 -05:00
Abhimanyu Saharan
0603dd1be4 Adds inventory item children view (#14217)
* adds inventory item children view #14112

* Use existing child_items relation

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-09 08:47:24 -05:00
Abhimanyu Saharan
1203d761f4 Adds mask length filters on ipaddress (#14218)
* adds mask length filters on ipaddress #14101

* Change IPaddress mask_length filter to multi-value; extend tests

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-11-09 08:46:14 -05:00
Abhimanyu Saharan
d2c727c0a2 review changes #13743 2023-11-09 08:36:39 -05:00
Abhimanyu Saharan
ac4b46b502 adds site column to power feeds #13743 2023-11-09 08:36:39 -05:00
Abhimanyu Saharan
6e8ee9db89 review changes #14113 2023-11-09 08:34:41 -05:00
Abhimanyu Saharan
94858ac13f adds parent to inventory item table #14113 2023-11-09 08:34:41 -05:00
Abhimanyu Saharan
b0f2de5bd7 order available columns #14219 2023-11-09 08:07:17 -05:00
Abhimanyu Saharan
60e98324c3 adds inventory items to interface #13723 2023-11-08 12:57:22 -05:00
Abhimanyu Saharan
66b9cdf141 adds import button on the contact assignment table #13669 2023-11-08 12:37:13 -05:00
Kenny Y
22e474ff96 Update attr in conditions example 2023-11-02 10:22:54 -04:00
Arthur Hanson
b3fb393490 14033 raise validation error if A and B term go to same object (#14050)
* 14033 raise validation error if A and B term go to same object

* 14033 move check to cable model clean

* 14033 fix tests
2023-11-01 16:30:10 -04:00
Jeremy Stretch
5b2f29480a Tweak translation issue form 2023-10-18 11:57:21 -04:00
Jeremy Stretch
809b049590 YAML fix 2023-10-18 11:29:31 -04:00
Jeremy Stretch
2a0a7d45aa Add GitHub issue template for translations 2023-10-18 11:24:14 -04:00
Jeremy Stretch
7efbfabc0b PRVB 2023-10-17 13:07:29 -04:00
Jeremy Stretch
d195f9c6ea Merge pull request #14057 from netbox-community/develop
Release v3.6.4
2023-10-17 13:04:39 -04:00
Jeremy Stretch
de298224f1 Pin django-mptt to v0.14.0, for Python 3.8 2023-10-17 12:48:42 -04:00
Jeremy Stretch
3fd8e48fac Release v3.6.4 2023-10-17 12:37:14 -04:00
Jeremy Stretch
ab9de43447 Changelog for #12336, #13957, #13962, #13972, #14025, #14042 2023-10-17 12:25:49 -04:00
Jeremy Stretch
51ef4fb920 Closes #13962: Add a copy-to-clipboard button to the key field of the API token creation form 2023-10-17 11:34:37 -04:00
Arthur Hanson
7983c2590e 14025 fix script name checking (#14030)
* 14025 fix script name checking

* 14025 fix script name checking

* 14025 add file extension validation and simplify get logic

* 14025 match start of string with regex

* 14025 backout changes to model_forms

* 14025 add filepatch checking to reports
2023-10-17 10:57:50 -04:00
Arthur Hanson
d77d45e795 12336 make region API calls atomic (#13942)
* 12336 make region API calls atomic

* 12336 switch to pg locks

* 12336 add locks to all views using mptt models

* 12336 fix ADVISORY_LOCK_KEYS reference

* 12336 review changes

* Tweak advisory lock numbering

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-10-17 10:35:01 -04:00
Arthur Hanson
a24864bc6d 14042 mptt cache count (#14048)
* 14042 fix cache count for mptt child delete

* 14042 add test

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-10-17 10:31:58 -04:00
Prince Kumar
c671ac2f28 Add dns_name filter on the IP Address page (#14046)
* Add dns_name filter on the IP Address page #13957

* add dns_name field in the filterset field and remove extra method
2023-10-17 10:06:33 -04:00
Arthur Hanson
18a813aa39 13972 allow filtering of cables if have terminations (#13949)
* 10769 allow filtering of cables if have terminations

* 10769 change to termianted

* 10769 add test case

* 10769 review cleanup
2023-10-17 09:32:42 -04:00
Jeremy Stretch
14447befb9 Changelog for #12872, #14013, #14023, #14026 2023-10-13 14:01:08 -04:00
Daniel Sheppard
06ed7ac8a5 Fixes: #14023 - Fixes bulk disconnecting with multiple components attached to the same cable (#14029)
* Fixes: #14023 - Fixes bulk disconnecting with multiple components attached to the same cable

* Update netbox/dcim/views.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/dcim/views.py

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>

* Update netbox/dcim/views.py

Co-authored-by: Daniel Sheppard <dans@dansheps.com>

* Code cleanup & i18n fix

* Restore original termination count logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-10-13 13:57:58 -04:00
Tobias Genannt
72f01b3e89 Fix #14026: Only get the needed amount of objects 2023-10-13 09:25:12 -04:00
Jeremy Stretch
2522056bd1 Closes #12872: Introduce DATA_UPLOAD_MAX_MEMORY_SIZE config parameter 2023-10-13 08:54:06 -04:00
Arthur
01c894e625 14013 fix device role filter 2023-10-13 08:51:22 -04:00
Jeremy Stretch
4286c1cde2 Closes #12831: Include circuit description in cable trace SVG image 2023-10-06 15:14:33 -04:00
Jeremy Stretch
383285fb94 Closes #13997: Update runner versions (#13998)
* Update runner versions

* Update stale & lock runners
2023-10-06 13:34:25 -04:00
Jeremy Stretch
e23b246d46 Changelog for #11987, #13440, #13746, #13876, #13950 2023-10-05 16:55:15 -04:00
Arthur
a543bd469a 11987 change cable bulk import to check if same cable 2023-10-05 16:48:48 -04:00
Arthur Hanson
d03859b27b 13746 fix available ips API for posting custom-fields (#13889) 2023-10-05 15:53:57 -04:00
Arthur Hanson
bbb133019d 13815 document view permissions for scripts (#13943)
* 13815 document view permissions for scripts

* Replicate permissions note for reports

* Remove duplicated text

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-10-05 15:15:50 -04:00
sleepinggenius2
285187542d Adds selection custom field labels to UI 2023-10-05 15:02:22 -04:00
Arthur
4d13f4d252 13850 add requests to requirements 2023-10-05 13:37:38 -04:00
Jeremy Stretch
e4a9cad756 Changelog for #12328, #13064, #13872, #13910, #13944 2023-10-04 14:11:28 -04:00
Jeremy Stretch
b93b331d86 Fixes #13966: Restore 'last login' column on users table 2023-10-04 14:09:29 -04:00
Jeremy Stretch
a46255ddda Fixes #13064: Ensure unchecked checkboxes do not revert to original values upon HTMX form refresh 2023-10-04 11:57:52 -04:00
Arthur Hanson
6093debb71 12328 update GFK object in clean (#13946)
* 12328 update GFK object in clean

* Add missing import statement

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-10-03 15:41:40 -04:00
yash-pal1
6dc560596d added device button under platform view pre-populated role field instead of platform field 2023-10-03 15:09:10 -04:00
Arthur
5cb1a6b790 13872 fix bulk import 2023-10-03 14:52:59 -04:00
Arthur
ef460a38ed 13944 fix report detail api 2023-10-03 14:39:46 -04:00
Jeremy Stretch
786f0cc7f3 PRVB 2023-09-26 16:31:33 -04:00
86 changed files with 776 additions and 241 deletions

View File

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

View File

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

37
.github/ISSUE_TEMPLATE/translation.yaml vendored Normal file
View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@@ -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.
![Adding the run action to a permission](../media/admin_ui_run_permission.png)

View File

@@ -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"
}

View File

@@ -1,5 +1,90 @@
# NetBox v3.6
## 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

View File

@@ -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")

View File

@@ -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', ''):

View 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:

View File

@@ -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()
)

View File

@@ -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'),

View File

@@ -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

View File

@@ -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(),
@@ -1069,7 +1066,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 +1742,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 +1813,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()

View File

@@ -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}")

View File

@@ -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')
@@ -910,7 +910,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 +979,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)
@@ -1136,7 +1143,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 +1165,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 +1187,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 +1204,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,7 +1224,7 @@ 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')),
)
vdc_id = DynamicModelMultipleChoiceField(
@@ -1324,7 +1331,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 +1353,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 +1374,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 +1389,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 +1400,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(),

View File

@@ -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'),

View File

@@ -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

View File

@@ -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()

View File

@@ -160,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}')

View File

@@ -466,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'
)
@@ -625,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'
)
@@ -636,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')
@@ -933,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'
)
@@ -941,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 = (

View File

@@ -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',

View File

@@ -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)

View File

@@ -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)
@@ -2991,6 +2993,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
#

View File

@@ -283,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}

View File

@@ -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):

View File

@@ -232,6 +232,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

View File

@@ -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

View File

@@ -457,7 +457,7 @@ class ConfigContextTestCase(
'platforms': [],
'tenant_groups': [],
'tenants': [],
'device_types': [devicetype.id,],
'device_types': [devicetype.id],
'tags': [],
'data': '{"foo": 123}',
}

View File

@@ -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')

View File

@@ -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()

View File

@@ -266,6 +266,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 +280,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 +290,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(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)

View File

@@ -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:
@@ -1227,3 +1233,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)'),
)

View File

@@ -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):

View File

@@ -295,7 +295,7 @@ 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')),
@@ -357,6 +357,10 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
dns_name = forms.CharField(
required=False,
label=_('DNS Name')
)
tag = TagFilterField(model)
@@ -519,6 +523,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)

View File

@@ -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(

View File

@@ -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]

View File

@@ -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 = (

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.3'
VERSION = '3.6.6'
# 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 = {

View File

@@ -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)

View File

@@ -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):

View File

@@ -394,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,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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);
}
}

View File

@@ -15,11 +15,6 @@
{% block content %}
<div class="row">
<div class="col col-md-12">
{% if key and not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
<h5 class="card-header">{% trans "Token" %}</h5>
<div class="card-body">

View File

@@ -5,6 +5,7 @@
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
@@ -15,16 +16,7 @@
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% if object.site.region %}
{% for region in object.site.region.get_ancestors %}
{{ region|linkify }} /
{% endfor %}
{{ object.site.region|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{% nested_tree object.site.region %}</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
@@ -32,16 +24,7 @@
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
{{ location|linkify }} /
{% endfor %}
{{ object.location|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack" %}</th>

View File

@@ -13,7 +13,7 @@
{% block extra_controls %}
{% if perms.dcim.add_device %}
<a href="{% url 'dcim:device_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
<a href="{% url 'dcim:device_add' %}?platform={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Device" %}
</a>
{% endif %}

View File

@@ -4,6 +4,7 @@
{% load static %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
@@ -15,26 +16,18 @@
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Site" %}</th>
<th scope="row">{% trans "Region" %}</th>
<td>
{% if object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
{% nested_tree object.site.region %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
{{ location|linkify }} /
{% endfor %}
{{ object.location|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{% nested_tree object.location %}</td>
</tr>
<tr>
<th scope="row">{% trans "Facility ID" %}</th>

View File

@@ -4,6 +4,7 @@
{% load static %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@@ -20,25 +21,24 @@
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% with rack=object.rack %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if rack.site.region %}
{{ rack.site.region|linkify }} /
{% endif %}
{{ rack.site|linkify }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{{ rack.location|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack" %}</th>
<td>{{ rack|linkify }}</td>
</tr>
{% endwith %}
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.rack.site.region %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.rack.site|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Location" %}</th>
<td>{{ object.rack.location|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Rack" %}</th>
<td>{{ object.rack|linkify }}</td>
</tr>
</table>
</div>
</div>

View File

@@ -3,6 +3,7 @@
{% load plugins %}
{% load tz %}
{% load i18n %}
{% load mptt %}
{% block breadcrumbs %}
{{ block.super }}
@@ -29,27 +30,13 @@
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% if object.region %}
{% for region in object.region.get_ancestors %}
{{ region|linkify }} /
{% endfor %}
{{ object.region|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
{% nested_tree object.region %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>
{% if object.group %}
{% for group in object.group.get_ancestors %}
{{ group|linkify }} /
{% endfor %}
{{ object.group|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
{% nested_tree object.group %}
</td>
</tr>
<tr>

View File

@@ -0,0 +1,6 @@
{% comment %}
Include a hidden field of the same name to ensure that unchecked checkboxes
are always included in the submitted form data.
{% endcomment %}
<input type="hidden" name="{{ widget.name }}" value="">
{% include "django/forms/widgets/input.html" %}

View File

@@ -67,6 +67,7 @@ Context:
<input type="hidden" name="import_method" value="upload" />
{% render_field form.upload_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group">
<div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>
@@ -88,6 +89,7 @@ Context:
{% render_field form.data_source %}
{% render_field form.data_file %}
{% render_field form.format %}
{% render_field form.csv_delimiter %}
<div class="form-group">
<div class="col col-md-12 text-end">
<button type="submit" name="file_submit" class="btn btn-primary">{% trans "Submit" %}</button>

View File

@@ -3,6 +3,7 @@
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
@@ -44,18 +45,17 @@
{% endif %}
</td>
</tr>
{% if object.site.region %}
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if object.site %}
{% if object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{{ object.site|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN" %}</th>

View File

@@ -3,6 +3,7 @@
{% load render_table from django_tables2 %}
{% load plugins %}
{% load i18n %}
{% load mptt %}
{% block content %}
<div class="row">
@@ -13,18 +14,17 @@
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% if object.site.region %}
<tr>
<th scope="row">{% trans "Region" %}</th>
<td>
{% nested_tree object.site.region %}
</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Site" %}</th>
<td>
{% if object.site %}
{% if object.site.region %}
{{ object.site.region|linkify }} /
{% endif %}
{{ object.site|linkify }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>{{ object.site|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>

View File

@@ -3,7 +3,7 @@ from rest_framework.routers import APIRootView
from circuits.models import Circuit
from dcim.models import Device, Rack, Site
from ipam.models import IPAddress, Prefix, VLAN, VRF
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from tenancy import filtersets
from tenancy.models import *
from utilities.utils import count_related
@@ -23,7 +23,7 @@ class TenancyRootView(APIRootView):
# Tenants
#
class TenantGroupViewSet(NetBoxModelViewSet):
class TenantGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@@ -58,7 +58,7 @@ class TenantViewSet(NetBoxModelViewSet):
# Contacts
#
class ContactGroupViewSet(NetBoxModelViewSet):
class ContactGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = ContactGroup.objects.add_related_count(
ContactGroup.objects.all(),
Contact,

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import TagsMixin
from netbox.models.features import ExportTemplatesMixin, TagsMixin
from tenancy.choices import *
__all__ = (
@@ -109,7 +109,7 @@ class Contact(PrimaryModel):
return reverse('tenancy:contact', args=[self.pk])
class ContactAssignment(ChangeLoggedModel, TagsMixin):
class ContactAssignment(ChangeLoggedModel, ExportTemplatesMixin, TagsMixin):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE

View File

@@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
verbose_name=_('Role'),
linkify=True
)
contact_group = tables.Column(
accessor=Accessor('contact__group'),
verbose_name=_('Group'),
linkify=True
)
contact_title = tables.Column(
accessor=Accessor('contact__title'),
verbose_name=_('Contact Title')
@@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
model = ContactAssignment
fields = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions'
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
'actions'
)
default_columns = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'

View File

@@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView):
filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm
table = tables.ContactAssignmentTable
actions = ('export', 'bulk_edit', 'bulk_delete')
actions = ('export', 'bulk_edit', 'bulk_delete', 'import')
@register_model_view(ContactAssignment, 'edit')

View File

@@ -52,6 +52,16 @@ class UserSerializer(ValidatedModelSerializer):
return user
def update(self, instance, validated_data):
"""
Ensure proper updated password hash generation.
"""
password = validated_data.pop('password', None)
if password is not None:
instance.set_password(password)
return super().update(instance, validated_data)
@extend_schema_field(OpenApiTypes.STR)
def get_display(self, obj):
if full_name := obj.get_full_name():

View File

@@ -114,6 +114,9 @@ class UserTokenForm(BootstrapMixin, forms.ModelForm):
help_text=_(
'Keys must be at least 40 characters in length. <strong>Be sure to record your key</strong> prior to '
'submitting this form, as it may no longer be accessible once the token has been created.'
),
widget=forms.TextInput(
attrs={'data-clipboard': 'true'}
)
)
allowed_ips = SimpleArrayField(

View File

@@ -52,7 +52,7 @@ class UserTable(NetBoxTable):
model = NetBoxUser
fields = (
'pk', 'id', 'username', 'first_name', 'last_name', 'email', 'groups', 'is_active', 'is_staff',
'is_superuser',
'is_superuser', 'last_login',
)
default_columns = ('pk', 'username', 'first_name', 'last_name', 'email', 'is_active')

View File

@@ -54,6 +54,38 @@ class UserTest(APIViewTestCases.APIViewTestCase):
)
User.objects.bulk_create(users)
def test_that_password_is_changed(self):
"""
Test that password is changed
"""
obj_perm = ObjectPermission(
name='Test permission',
actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
user_credentials = {
'username': 'user1',
'password': 'abc123',
}
user = User.objects.create_user(**user_credentials)
data = {
'password': 'newpassword'
}
url = reverse('users-api:user-detail', kwargs={'pk': user.id})
response = self.client.patch(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 200)
updated_user = User.objects.get(id=user.id)
self.assertTrue(updated_user.check_password(data['password']))
class GroupTest(APIViewTestCases.APIViewTestCase):
model = Group

View File

@@ -62,7 +62,7 @@ def post_save_receiver(sender, instance, created, **kwargs):
update_counter(parent_model, new_pk, counter_name, 1)
def post_delete_receiver(sender, instance, **kwargs):
def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
"""
@@ -72,7 +72,9 @@ def post_delete_receiver(sender, instance, **kwargs):
# Decrement the parent's counter by one
if parent_pk is not None:
update_counter(parent_model, parent_pk, counter_name, -1)
# MPTT sends two delete signals for child elements so guard against multiple decrements
if not origin or origin == instance:
update_counter(parent_model, parent_pk, counter_name, -1)
#

View File

@@ -40,7 +40,7 @@ def parse_numeric_range(string, base=10):
except ValueError:
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
values.extend(range(begin, end))
return list(set(values))
return sorted(set(values))
def parse_alphanumeric_range(string):

View File

@@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
)
for header in HTTP_HEADERS:
if header in request.META:
client_ip = request.META[header].split(',')[0]
client_ip = request.META[header].split(',')[0].partition(':')[0]
try:
return IPAddress(client_ip)
except ValueError:

View File

@@ -29,6 +29,14 @@
{{ label }}
</label>
</div>
{# Include a copy-to-clipboard button #}
{% elif 'data-clipboard' in field.field.widget.attrs %}
<div class="input-group">
{{ field }}
<button type="button" title="{% trans "Copy to clipboard" %}" class="btn btn-outline-dark border-input copy-content" data-clipboard-target="#{{ field.id_for_label }}">
<i class="mdi mdi-content-copy"></i>
</button>
</div>
{# Default field rendering #}
{% else %}
{{ field }}

View File

@@ -1,6 +1,7 @@
from django import template
from django.http import QueryDict
from extras.choices import CustomFieldTypeChoices
from utilities.utils import dict_to_querydict
__all__ = (
@@ -38,6 +39,11 @@ def customfield_value(customfield, value):
customfield: A CustomField instance
value: The custom field value applied to an object
"""
if value:
if customfield.type == CustomFieldTypeChoices.TYPE_SELECT:
value = customfield.get_choice_label(value)
elif customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
value = [customfield.get_choice_label(v) for v in value]
return {
'customfield': customfield,
'value': value,

View File

@@ -0,0 +1,20 @@
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.simple_tag()
def nested_tree(obj):
"""
Renders the entire hierarchy of a recursively-nested object (such as Region or SiteGroup).
"""
if not obj:
return mark_safe('&mdash;')
nodes = obj.get_ancestors(include_self=True)
return mark_safe(
' / '.join(
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
)
)

View File

@@ -1,7 +1,11 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from dcim.models import *
from utilities.testing.utils import create_test_device
from users.models import ObjectPermission
from utilities.testing.base import TestCase
from utilities.testing.utils import create_test_device, create_test_user
class CountersTest(TestCase):
@@ -10,7 +14,6 @@ class CountersTest(TestCase):
"""
@classmethod
def setUpTestData(cls):
# Create devices
device1 = create_test_device('Device 1')
device2 = create_test_device('Device 2')
@@ -79,3 +82,25 @@ class CountersTest(TestCase):
device2.refresh_from_db()
self.assertEqual(device1.interface_count, 1)
self.assertEqual(device2.interface_count, 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_mptt_child_delete(self):
device1, device2 = Device.objects.all()
inventory_item1 = InventoryItem.objects.create(device=device1, name='Inventory Item 1')
inventory_item2 = InventoryItem.objects.create(device=device1, name='Inventory Item 2', parent=inventory_item1)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 2)
# Setup bulk_delete for the inventory items
self.add_permissions('dcim.delete_inventoryitem')
pk_list = device1.inventoryitems.values_list('pk', flat=True)
data = {
'pk': pk_list,
'confirm': True,
'_confirm': True, # Form button
}
# Try POST with model-level permission
self.client.post(reverse("dcim:inventoryitem_bulk_delete"), data)
device1.refresh_from_db()
self.assertEqual(device1.inventory_item_count, 0)

View File

@@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from extras.models import ConfigTemplate
from ipam.filtersets import PrimaryIPFilterSet
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -114,7 +115,8 @@ class VirtualMachineFilterSet(
NetBoxModelFilterSet,
TenancyFilterSet,
ContactModelFilterSet,
LocalConfigContextFilterSet
LocalConfigContextFilterSet,
PrimaryIPFilterSet,
):
status = django_filters.MultipleChoiceFilter(
choices=VirtualMachineStatusChoices,

View File

@@ -294,9 +294,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# Check interface sites. First interface should set site, further interfaces will either continue the
# loop or reset back to no site and break the loop.
for interface in interfaces:
vm_site = interface.virtual_machine.site or interface.virtual_machine.cluster.site
if site is None:
site = interface.virtual_machine.cluster.site
elif interface.virtual_machine.cluster.site is not site:
site = vm_site
elif vm_site is not site:
site = None
break

View File

@@ -200,7 +200,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
platform = DynamicModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(),
required=False
required=False,
selector=True
)
local_context_data = JSONField(
required=False,

View File

@@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
IPAddress(address='192.0.2.3/24', assigned_object=None),
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
IPAddress(address='2001:db8::3/64', assigned_object=None),
)
IPAddress.objects.bulk_create(ipaddresses)
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
def test_name(self):
params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
@@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_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)
class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VMInterface.objects.all()

View File

@@ -1,6 +1,6 @@
from rest_framework.routers import APIRootView
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets import NetBoxModelViewSet, MPTTLockedMixin
from wireless import filtersets
from wireless.models import *
from . import serializers
@@ -14,7 +14,7 @@ class WirelessRootView(APIRootView):
return 'Wireless'
class WirelessLANGroupViewSet(NetBoxModelViewSet):
class WirelessLANGroupViewSet(MPTTLockedMixin, NetBoxModelViewSet):
queryset = WirelessLANGroup.objects.add_related_count(
WirelessLANGroup.objects.all(),
WirelessLAN,

View File

@@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel
from dcim.choices import LinkStatusChoices
from dcim.constants import WIRELESS_IFACE_TYPES

View File

@@ -1,35 +1,36 @@
bleach==6.0.0
Django==4.2.5
django-cors-headers==4.2.0
bleach==6.1.0
Django==4.2.7
django-cors-headers==4.3.1
django-debug-toolbar==4.2.0
django-filter==23.3
django-filter==23.4
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14
django-mptt==0.14.0
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.3.0
django-rich==1.7.0
django-rq==2.8.1
django-redis==5.4.0
django-rich==1.8.0
django-rq==2.9.0
django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==6.0.1
django-timezone-field==6.1.0
djangorestframework==3.14.0
drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.9.1
drf-spectacular==0.26.5
drf-spectacular-sidecar==2023.10.1
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.4.2
mkdocstrings[python-legacy]==0.23.0
mkdocs-material==9.4.14
mkdocstrings[python-legacy]==0.24.0
netaddr==0.9.0
Pillow==10.0.1
psycopg[binary,pool]==3.1.11
Pillow==10.1.0
psycopg[binary,pool]==3.1.13
PyYAML==6.0.1
sentry-sdk==1.31.0
social-auth-app-django==5.3.0
social-auth-core[openidconnect]==4.4.2
requests==2.31.0
sentry-sdk==1.38.0
social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.5.1
svgwrite==1.4.3
tablib==3.5.0
tzdata==2023.3