Compare commits

..

97 Commits

Author SHA1 Message Date
Jeremy Stretch
46b933a5aa Merge pull request #14616 from netbox-community/develop
Release v3.6.8
2023-12-27 16:12:13 -05:00
Jeremy Stretch
07da3f6d33 Release v3.6.8 2023-12-27 16:00:16 -05:00
Jeremy Stretch
0613e8e95c Fixes #14613: Fix display of current configuration parameters 2023-12-27 15:32:11 -05:00
Jeremy Stretch
113c60a44a Fixes #13909: Ignore empty choices when populating dynamic choice fields from initial data 2023-12-27 14:32:40 -05:00
Jeremy Stretch
8a237561ef Closes #14596: Match against description field when searching for devices 2023-12-27 13:49:39 -05:00
Jeremy Stretch
cc0fc03ec3 Changelog for #11039, #11816, #12731, #13606, #13649, #13812, #14532 2023-12-27 13:45:06 -05:00
Jeremy Stretch
b955751349 Fixes #14517: Ensure reservations tab is always displayed under rack view 2023-12-27 13:42:26 -05:00
Jeremy Stretch
d6c8d1581c Closes #11039: List parent prefixes under IP range view 2023-12-27 12:53:30 -05:00
Jeremy Stretch
e6642b5f5b Fixes #11816: Detach group/site validation error from group field 2023-12-27 12:51:51 -05:00
Jeremy Stretch
a67236fc3c Fixes #13812: Record data source sync failure when run via syncdatasource command 2023-12-27 12:51:03 -05:00
Jeremy Stretch
634681a72e Fixes #13606: Fix filtering by null for multiselect custom fields 2023-12-27 12:49:31 -05:00
Jeremy Stretch
031b7540b3 Fixes #13741: Update docs to correctly reflect inventory item uniqueness requirements 2023-12-26 13:35:03 -05:00
Jeremy Stretch
43909ee33f Fixes #13649: Permit zero-length cables 2023-12-26 09:27:58 -05:00
Jeremy Stretch
99467e8f66 Fixes #12731: Support custom validation for many-to-many fields (#14516)
* WIP

* Enforce custom validators during bulk edit

* Add bulk edit M2M validation test

* Clean up tests

* Add custom validation test for bulk import

* Misc cleanup
2023-12-22 10:01:05 -05:00
Jeremy Stretch
0d08205ab1 Fixes #14532: Device/VM change record should accurately reflect when primary/OOB IP is deleted 2023-12-22 08:47:51 -05:00
Jeremy Stretch
c289dda649 Changelog for #14507, #14538, #14549, #14560, #14575 2023-12-21 16:36:24 -05:00
Daniel Sheppard
169207058f Update search to add note 2023-12-21 16:27:43 -05:00
Jeremy Stretch
e5c565cbf4 Closes #14119: Remove redundant check for to_objectchange() 2023-12-21 16:26:20 -05:00
Jeremy Stretch
f0b9008529 Fixes #14575: Fix display of the tags column under VDC table 2023-12-21 16:00:44 -05:00
Daniel Sheppard
8dfec7e2b2 Closes #14538 - Add available_at_site filter (#14541)
* Closes #14538 - Add available_at_site filter

* Add tests

* Fix tests
2023-12-21 15:40:57 -05:00
Markku Leiniö
c1cf037eaf Print NetBox version in upgrade.sh (#14547) 2023-12-21 15:13:40 -05:00
Azmodeszer
3f4a65cc5c added ! to safe characters 2023-12-21 15:10:38 -05:00
Prince Kumar
12beac4f1a fix the result of script jobs #14549 2023-12-20 15:15:02 -05:00
Jeremy Stretch
ec245b968f PRVB 2023-12-15 16:46:53 -05:00
Jeremy Stretch
f1d4011b40 Merge pull request #14542 from netbox-community/develop
Release v3.6.7
2023-12-15 16:44:46 -05:00
Jeremy Stretch
4cdc30a7c5 Release v3.6.7 2023-12-15 16:25:24 -05:00
kkthxbye
8d39181842 Fixes #12751 - Usability improvements for object selector (#14387)
* Usability improvements for object selector:
* Adds preselected filters
* Applies the filter on selection instead of requiring the search button to be pushed

* Declare selector_fields on base form class

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-15 16:07:15 -05:00
Jeremy Stretch
c81869c795 Fixes #14533: Fix quick search under VLAN group VLANs list 2023-12-15 13:59:31 -05:00
Jeremy Stretch
929d4d2c95 Fixes #14522: Fix filtering contact assignments by group 2023-12-15 13:58:50 -05:00
Jeremy Stretch
d14e4ab52b Changelog for #13983, #14081, #14148, #14467, #14505, #14512, #14515 2023-12-14 17:12:29 -05:00
Daniel Sheppard
8a4233aca1 Update create_userconfig to receive signals from NetBoxUser model in addition to User model. 2023-12-14 17:07:57 -05:00
Jeremy Stretch
5508e125ba Fixes #14512: Omit unused queryset annotations for REST API requests using brief mode 2023-12-14 16:49:18 -05:00
Arthur Hanson
69bf1472d2 13983 Add nested arrays for extra_choices in CustomFieldChoiceSet (#14470)
* 13983 split array fields in CSV data for CustomFieldChoices

* 13983 fix help text

* 13983 update tests

* 13983 use re for split

* 13983 replace escaped chars

* 13983 fix escape handling

* 13983 fix escape handling

* 13983 fix escape handling
2023-12-14 15:18:56 -05:00
Arthur Hanson
b93735861d Fixes #14081: Fix cached counters on delete for parent-child items (#14131)
* 14081 fixed cached counters on delete for parent-child items

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-12 16:53:04 -05:00
Arthur Hanson
6939ae4a47 14467 change ChoiceField separator from comma to colon (#14469)
* 14467 change ChoiceField separator from comma to colon

* 14467 fix test

* 14467 fix test

* 14467 use regex for colon detection

* 14467 update tests
2023-12-12 14:31:39 -05:00
Prince Kumar
81fa4265da add tags field in L2VPN Termination 2023-12-12 14:23:16 -05:00
Jeremy Stretch
35be4f05ef Add note to bug reports section 2023-12-11 10:10:28 -05:00
Jeremy Stretch
2ef023a160 Changelog for #14249, #14390, #14392, #14397, #14401, #14432, #14448 2023-12-07 16:34:49 -05:00
Jeremy Stretch
9d7192202d Fixes #14392: Fix admin UI bulk actions 2023-12-07 16:31:21 -05:00
Jeremy Stretch
95a8415e2d Add deployment type to bug report template 2023-12-07 16:21:15 -05:00
Jeremy Stretch
e59ee3e01e Fixes #14397: Pass a mutable copy of request data when provisioning available IPs 2023-12-07 11:20:03 -05:00
Abhimanyu Saharan
92bdaa2120 Fixes IPv6 detection from headers (#14456)
* fixes client ip detection for v6

* adds test for get_client_ip

* Employ urlparse() to strip port numbers from IPs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-12-07 09:45:30 -05:00
Jeremy Stretch
fe3f21105c Fixes #14448: Fix exception when creating a power feed with rack and panel in different sites 2023-12-06 15:28:47 -05:00
Jeremy Stretch
32264ac3e3 Fixes #14322: Populate default custom field values when instantiating templated device components 2023-12-06 15:21:34 -05:00
Arthur
b34daeaacb 14401 review changes - remove migration 2023-12-06 15:16:03 -05:00
Arthur
d2c3a39ebb 14401 validate rack startion position > 0 2023-12-06 15:16:03 -05:00
Jeremy Stretch
d10ac9b4a7 Closes #12623: Document need for core.sync_datasource permission 2023-12-05 14:03:38 -05:00
Abhimanyu Saharan
b21ed6a334 adds optional classes parameter #14390 2023-12-05 13:51:28 -05:00
Jeremy Stretch
9d09916f6e PRVB 2023-11-29 19:32:45 -05:00
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
106 changed files with 1408 additions and 296 deletions

View File

@@ -10,16 +10,25 @@ body:
installation. If you're having trouble with installation or just looking for
assistance with using NetBox, please visit our
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
- type: dropdown
attributes:
label: Deployment Type
description: How are you running NetBox?
options:
- Self-hosted
- NetBox Cloud
validations:
required: true
- type: input
attributes:
label: NetBox version
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v3.6.4
placeholder: v3.6.8
validations:
required: true
- type: dropdown
attributes:
label: Python version
label: Python Version
description: What version of Python are you currently running?
options:
- "3.8"

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.6.4
placeholder: v3.6.8
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

@@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
## :bug: Reporting Bugs
:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.

View File

@@ -53,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/

View File

@@ -8,6 +8,9 @@ When entering a search query, the user can choose a specific lookup type: exact
Custom fields defined by NetBox administrators are also included in search results if configured with a search weight. Additionally, NetBox plugins can register their own custom models for inclusion alongside core models.
!!! note
NetBox does not index any static choice field's (including custom fields of type "Selection" or "Multiple selection").
## Saved Filters
Each type of object in NetBox is accompanied by an extensive set of filters, each tied to a specific attribute, which enable the creation of complex queries. Often you'll find that certain queries are used routinely to apply some set of prescribed conditions to a query. Once a set of filters has been applied, NetBox offers the option to save it for future use.

View File

@@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
!!! info
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
@@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
* Export templates
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
!!! note "Permissions"
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.

View File

@@ -2,6 +2,9 @@
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
!!! note "Permissions"
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
The following features support the use of synchronized data:
* [Configuration templates](../features/configuration-rendering.md)

View File

@@ -19,7 +19,7 @@ The parent inventory item to which this item is assigned (optional).
### Name
The inventory item's name. Must be unique to the parent device.
The inventory item's name. If the inventory item is assigned to a parent item, its name must be unique among its siblings (all items belonging to the same parent item).
### Label

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,111 @@
# NetBox v3.6
## v3.6.8 (2023-12-27)
### Enhancements
* [#11039](https://github.com/netbox-community/netbox/issues/11039) - List parent prefixes under IP range view
* [#14507](https://github.com/netbox-community/netbox/issues/14507) - Print new NetBox version when running upgrade script
* [#14538](https://github.com/netbox-community/netbox/issues/14538) - Add the `available_at_site` filter for VLANs
* [#14596](https://github.com/netbox-community/netbox/issues/14596) - Match against description field when searching for devices
### Bug Fixes
* [#11816](https://github.com/netbox-community/netbox/issues/11816) - Correct display of error message when attempting invalid VLAN site & group assignment
* [#12731](https://github.com/netbox-community/netbox/issues/12731) - Fix custom validation for many-to-many fields
* [#13606](https://github.com/netbox-community/netbox/issues/13606) - Fix filtering custom multi-choice fields by null
* [#13649](https://github.com/netbox-community/netbox/issues/13649) - Correct calculation of absolute lengths for zero-length cables
* [#13812](https://github.com/netbox-community/netbox/issues/13812) - Update status of remote data source when syncing fails via `syncdatasource` management command
* [#13909](https://github.com/netbox-community/netbox/issues/13909) - Fix cloning of objects which have a multi-choice custom field
* [#14517](https://github.com/netbox-community/netbox/issues/14517) - Ensure reservations tab is always displayed under rack view
* [#14532](https://github.com/netbox-community/netbox/issues/14532) - Device/VM change record should accurately reflect when primary/OOB IP is deleted
* [#14549](https://github.com/netbox-community/netbox/issues/14549) - Fix association of job results when executing scripts via `runscript` management command
* [#14560](https://github.com/netbox-community/netbox/issues/14560) - Do not escape exclamation marks in custom link URLs
* [#14575](https://github.com/netbox-community/netbox/issues/14575) - Fix display of the tags column under VDC table
* [#14613](https://github.com/netbox-community/netbox/issues/14613) - Fix display of current configuration parameters in UI
---
## v3.6.7 (2023-12-15)
### Enhancements
* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
### Bug Fixes
* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
---
## v3.6.6 (2023-11-29)
### Enhancements
* [#13735](https://github.com/netbox-community/netbox/issues/13735) - Show complete region hierarchy in UI for all relevant objects
### Bug Fixes
* [#14056](https://github.com/netbox-community/netbox/issues/14056) - Record a pre-change snapshot when bulk editing objects via CSV
* [#14187](https://github.com/netbox-community/netbox/issues/14187) - Raise a validation error when attempting to create a duplicate script or report
* [#14199](https://github.com/netbox-community/netbox/issues/14199) - Fix jobs list for reports with a custom name
* [#14239](https://github.com/netbox-community/netbox/issues/14239) - Fix CustomFieldChoiceSet search filter
* [#14242](https://github.com/netbox-community/netbox/issues/14242) - Enable export templates for contact assignments
* [#14299](https://github.com/netbox-community/netbox/issues/14299) - Webhook timestamps should be in proper ISO 8601 format
* [#14325](https://github.com/netbox-community/netbox/issues/14325) - Fix numeric ordering of service ports
* [#14339](https://github.com/netbox-community/netbox/issues/14339) - Correctly hash local user password when set via REST API
* [#14343](https://github.com/netbox-community/netbox/issues/14343) - Fix ordering ASN table by ASDOT column
* [#14346](https://github.com/netbox-community/netbox/issues/14346) - Fix running reports via REST API
* [#14349](https://github.com/netbox-community/netbox/issues/14349) - Fix custom validation support for remote data sources
* [#14363](https://github.com/netbox-community/netbox/issues/14363) - Fix bulk editing of interfaces assigned to VM with no cluster
---
## v3.6.5 (2023-11-09)
### Enhancements
* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
### Bug Fixes
* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
---
## v3.6.4 (2023-10-17)
### Enhancements

View File

@@ -110,6 +110,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
required=False,

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

@@ -1,5 +1,6 @@
from django.core.management.base import BaseCommand, CommandError
from core.choices import DataSourceStatusChoices
from core.models import DataSource
@@ -33,9 +34,13 @@ class Command(BaseCommand):
for i, datasource in enumerate(datasources, start=1):
self.stdout.write(f"[{i}] Syncing {datasource}... ", ending='')
self.stdout.flush()
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
try:
datasource.sync()
self.stdout.write(datasource.get_status_display())
self.stdout.flush()
except Exception as e:
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
raise e
if len(options['name']) > 1:
self.stdout.write(f"Finished.")

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

@@ -1,4 +1,5 @@
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, redirect
from extras.models import ConfigRevision
@@ -153,9 +154,11 @@ class ConfigView(generic.ObjectView):
queryset = ConfigRevision.objects.all()
def get_object(self, **kwargs):
if config := self.queryset.first():
return config
# Instantiate a dummy default config if none has been created yet
return ConfigRevision(
data=get_config().defaults
)
revision_id = cache.get('config_version')
try:
return ConfigRevision.objects.get(pk=revision_id)
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
return ConfigRevision(
data=get_config()
)

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(),
@@ -1021,6 +1018,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(description_icontains=value.strip()) |
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
@@ -1069,7 +1067,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.exclude(devicebays__isnull=value)
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
field_name='device',
queryset=Device.objects.all(),

View File

@@ -164,6 +164,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
status = forms.MultipleChoiceField(
label=_('Status'),
choices=SiteStatusChoices,
@@ -247,6 +248,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -419,6 +421,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -543,6 +546,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)),
(_('Weight'), ('weight', 'weight_unit')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -619,6 +623,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
class PlatformFilterForm(NetBoxModelFilterSetForm):
model = Platform
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
@@ -653,6 +658,7 @@ class DeviceFilterForm(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
))
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -996,6 +1002,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1227,6 +1234,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
selector_fields = ('filter_id', 'q', 'device_id')
vdc_id = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,

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

@@ -1,5 +1,6 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
import django.core.validators
from django.db import migrations, models
@@ -12,6 +13,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='rack',
name='starting_unit',
field=models.PositiveSmallIntegerField(default=1),
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
),
]

View File

@@ -0,0 +1,22 @@
from django.db import migrations
def update_cable_lengths(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
# Set the absolute length for any zero-length Cables
Cable.objects.filter(length=0).update(_abs_length=0)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0181_rename_device_role_device_role'),
]
operations = [
migrations.RunPython(
code=update_cable_lengths,
reverse_code=migrations.RunPython.noop
),
]

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()
@@ -190,7 +201,7 @@ class Cable(PrimaryModel):
_created = self.pk is None
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
if self.length is not None and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None

View File

@@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
from extras.models import ConfigContextModel
from extras.models import ConfigContextModel, CustomField
from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
@@ -985,11 +985,17 @@ class Device(
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
# Set default values for any applicable custom fields
model = queryset.model.component_model
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
for component in components:
component.custom_field_data = cf_defaults
if bulk_create:
components = [obj.instantiate(device=self) for obj in queryset]
if not components:
return
model = components[0]._meta.model
model.objects.bulk_create(components)
# Manually send the post_save signal for each of the newly created components
for component in components:
@@ -1002,8 +1008,7 @@ class Device(
update_fields=None
)
else:
for obj in queryset:
component = obj.instantiate(device=self)
for component in components:
component.save()
def save(self, *args, **kwargs):

View File

@@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
# Rack must belong to same Site as PowerPanel
if self.rack and self.rack.site != self.power_panel.site:
raise ValidationError(_(
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
).format(
rack=self.rack,
rack_site=self.rack.site,

View File

@@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1),],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(

View File

@@ -274,7 +274,7 @@ class CableTraceSVG:
if cable.type:
# Include the cable type in the tooltip
description.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
description.append(f'{cable.length} {cable.get_length_unit_display()}')
else:
@@ -285,7 +285,7 @@ class CableTraceSVG:
description = []
if cable.type:
labels.append(cable.get_type_display())
if cable.length and cable.length_unit:
if cable.length is not None and cable.length_unit:
# Include the cable length in the tooltip
labels.append(f'{cable.length} {cable.get_length_unit_display()}')

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 = (
@@ -1064,7 +1078,7 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:vdc_list'
url_name='dcim:virtualdevicecontext_list'
)
class Meta(NetBoxTable.Meta):

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

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

@@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import TestCase
from circuits.models import *
from dcim.choices import *
from dcim.models import *
from extras.models import CustomField
from tenancy.models import Tenant
from utilities.utils import drange
@@ -255,6 +257,23 @@ class DeviceTestCase(TestCase):
)
DeviceRole.objects.bulk_create(roles)
# Create a CustomField with a default value & assign it to all component models
cf1 = CustomField.objects.create(name='cf1', default='foo')
cf1.content_types.set(
ContentType.objects.filter(app_label='dcim', model__in=[
'consoleport',
'consoleserverport',
'powerport',
'poweroutlet',
'interface',
'rearport',
'frontport',
'modulebay',
'devicebay',
'inventoryitem',
])
)
# Create DeviceType components
ConsolePortTemplate(
device_type=device_type,
@@ -266,18 +285,18 @@ class DeviceTestCase(TestCase):
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
powerport = PowerPortTemplate(
device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
ppt.save()
powerport.save()
PowerOutletTemplate(
device_type=device_type,
name='Power Outlet 1',
power_port=ppt,
power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
@@ -288,19 +307,19 @@ class DeviceTestCase(TestCase):
mgmt_only=True
).save()
rpt = RearPortTemplate(
rearport = RearPortTemplate(
device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
rpt.save()
rearport.save()
FrontPortTemplate(
device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rpt,
rear_port=rearport,
rear_port_position=2
).save()
@@ -314,73 +333,93 @@ class DeviceTestCase(TestCase):
name='Device Bay 1'
).save()
InventoryItemTemplate(
device_type=device_type,
name='Inventory Item 1'
).save()
def test_device_creation(self):
"""
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
device = Device(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
role=DeviceRole.objects.first(),
name='Test Device 1'
)
d.save()
device.save()
ConsolePort.objects.get(
device=d,
consoleport = ConsolePort.objects.get(
device=device,
name='Console Port 1'
)
self.assertEqual(consoleport.cf['cf1'], 'foo')
ConsoleServerPort.objects.get(
device=d,
consoleserverport = ConsoleServerPort.objects.get(
device=device,
name='Console Server Port 1'
)
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
pp = PowerPort.objects.get(
device=d,
powerport = PowerPort.objects.get(
device=device,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
self.assertEqual(powerport.cf['cf1'], 'foo')
PowerOutlet.objects.get(
device=d,
poweroutlet = PowerOutlet.objects.get(
device=device,
name='Power Outlet 1',
power_port=pp,
power_port=powerport,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
)
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
Interface.objects.get(
device=d,
interface = Interface.objects.get(
device=device,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
)
self.assertEqual(interface.cf['cf1'], 'foo')
rp = RearPort.objects.get(
device=d,
rearport = RearPort.objects.get(
device=device,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
)
self.assertEqual(rearport.cf['cf1'], 'foo')
FrontPort.objects.get(
device=d,
frontport = FrontPort.objects.get(
device=device,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rp,
rear_port=rearport,
rear_port_position=2
)
self.assertEqual(frontport.cf['cf1'], 'foo')
ModuleBay.objects.get(
device=d,
modulebay = ModuleBay.objects.get(
device=device,
name='Module Bay 1'
)
self.assertEqual(modulebay.cf['cf1'], 'foo')
DeviceBay.objects.get(
device=d,
devicebay = DeviceBay.objects.get(
device=device,
name='Device Bay 1'
)
self.assertEqual(devicebay.cf['cf1'], 'foo')
inventoryitem = InventoryItem.objects.get(
device=device,
name='Inventory Item 1'
)
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
def test_multiple_unnamed_devices(self):

View File

@@ -695,8 +695,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
label=_('Reservations'),
badge=lambda obj: obj.reservations.count(),
permission='dcim.view_rackreservation',
weight=510,
hide_if_empty=True
weight=510
)
def get_children(self, request, parent):
@@ -2993,6 +2992,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
template_name = 'dcim/inventoryitem_bulk_delete.html'
@register_model_view(InventoryItem, 'children')
class InventoryItemChildrenView(generic.ObjectChildrenView):
queryset = InventoryItem.objects.all()
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),
permission='dcim.view_inventoryitem',
hide_if_empty=True,
weight=5000
)
def get_children(self, request, parent):
return parent.child_items.restrict(request.user, 'view')
#
# Inventory item roles
#

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

@@ -1,3 +1,5 @@
import re
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
@@ -76,7 +78,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,
help_text=_('Comma-separated list of field choices')
help_text=_(
'Quoted string of comma-separated field choices with optional labels separated by colon: '
'"choice1:First Choice,choice2:Second Choice"'
)
)
class Meta:
@@ -85,6 +90,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
'name', 'description', 'extra_choices', 'order_alphabetically',
)
def clean_extra_choices(self):
if isinstance(self.cleaned_data['extra_choices'], list):
data = []
for line in self.cleaned_data['extra_choices']:
try:
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))
return data
class CustomLinkImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(

View File

@@ -1,4 +1,5 @@
import json
import re
from django import forms
from django.conf import settings
@@ -95,19 +96,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
required=False,
help_text=mark_safe(_(
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
'comma. Example:'
) + ' <code>choice1,First Choice</code>')
'colon. Example:'
) + ' <code>choice1:First Choice</code>')
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, initial=initial, **kwargs)
# Escape colons in extra_choices
if 'extra_choices' in self.initial and self.initial['extra_choices']:
choices = []
for choice in self.initial['extra_choices']:
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
choices.append(choice)
self.initial['extra_choices'] = choices
def clean_extra_choices(self):
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
value, label = line.split(',', maxsplit=1)
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
value = value.replace('\\:', ':')
label = label.replace('\\:', ':')
except ValueError:
value, label = line, line
data.append((value, label))

View File

@@ -114,7 +114,7 @@ class Command(BaseCommand):
# Create the job
job = Job.objects.create(
object=module,
name=script.name,
name=script.class_name,
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
job_id=uuid.uuid4()
)

View File

@@ -10,7 +10,6 @@ from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator, ValidationError
from django.db import models
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
@@ -57,6 +56,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
return self.get_queryset().filter(content_types=content_type)
def get_defaults_for_model(self, model):
"""
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
"""
custom_fields = self.get_for_model(model).filter(default__isnull=False)
return {
cf.name: cf.default for cf in custom_fields
}
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
@@ -562,8 +570,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Multiselect
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
filter_class = filters.MultiValueCharFilter
kwargs['lookup_expr'] = 'has_key'
filter_class = filters.MultiValueArrayFilter
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

@@ -315,7 +315,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,;!')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)

View File

@@ -62,21 +62,20 @@ def handle_changed_object(sender, instance, **kwargs):
else:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Record an ObjectChange
if m2m_changed:
ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).update(
postchange_data=instance.to_objectchange(action).postchange_data
)
else:
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
queue = webhooks_queue.get()

View File

@@ -0,0 +1,265 @@
from django.test import TestCase
from django.test import override_settings
from circuits.api.serializers import ProviderSerializer
from circuits.forms import ProviderForm
from circuits.models import Provider
from ipam.models import ASN, RIR
from utilities.choices import CSVDelimiterChoices, ImportFormatChoices
from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data
class ModelFormCustomValidationTest(TestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
form = ProviderForm(data)
self.assertFalse(form.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
form = ProviderForm(data)
self.assertTrue(form.is_valid())
class BulkEditCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
for provider in providers:
provider.asns.set(asns)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_without_m2m(self):
"""
Check that custom validation rules do not interfere with bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
)
# Bulk edit the description without changing ASN assignments
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(
Provider.objects.filter(description=data['description']).count(),
len(data['pk'])
)
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_bulk_edit_m2m(self):
"""
Test that custom validation rules are enforced during bulk editing.
"""
data = {
'pk': list(Provider.objects.values_list('pk', flat=True)),
'_apply': '',
'description': 'New description',
}
self.add_permissions(
'circuits.view_provider',
'circuits.change_provider',
'ipam.view_asn',
)
# Change the ASN assignments
asn = ASN.objects.first()
data['asns'] = [asn.pk]
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
for provider in Provider.objects.all():
self.assertEqual(len(provider.asns.all()), 1)
# Attempt to remove the ASN assignments
data.pop('asns')
data['_nullify'] = 'asns'
request = {
'path': self._get_url('bulk_edit'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
for provider in Provider.objects.all():
self.assertTrue(provider.asns.exists())
class BulkImportCustomValidationTest(ModelViewTestCase):
model = Provider
@classmethod
def setUpTestData(cls):
create_tags('Tag1', 'Tag2', 'Tag3')
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_bulk_import_invalid(self):
"""
Test that custom validation rules are enforced during bulk import.
"""
csv_data = (
"name,slug",
"Provider 1,provider-1",
"Provider 2,provider-2",
"Provider 3,provider-3",
)
data = {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.COMMA,
}
self.add_permissions(
'circuits.view_provider',
'circuits.add_provider',
'extras.view_tag',
)
# Attempt to import providers without tags
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertFalse(Provider.objects.exists())
# Import providers successfully with tag assignments
csv_data = (
"name,slug,tags",
"Provider 1,provider-1,tag1",
"Provider 2,provider-2,tag2",
"Provider 3,provider-3,tag3",
)
data['data'] = '\n'.join(csv_data)
request = {
'path': self._get_url('import'),
'data': post_data(data),
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertTrue(Provider.objects.exists())
class APISerializerCustomValidationTest(APITestCase):
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'tags': {'required': True}}
]
})
def test_tags_validation(self):
"""
Check that custom validation rules work for tag assignment.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
tags = create_tags('Tag1', 'Tag2', 'Tag3')
data['tags'] = [tag.pk for tag in tags]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())
@override_settings(CUSTOM_VALIDATORS={
'circuits.provider': [
{'asns': {'required': True}}
]
})
def test_m2m_validation(self):
"""
Check that custom validation rules work for many-to-many fields.
"""
data = {
'name': 'Provider 1',
'slug': 'provider-1',
}
serializer = ProviderSerializer(data=data)
self.assertFalse(serializer.is_valid())
rir = RIR.objects.create(name='RIR 1', slug='rir-1')
asns = ASN.objects.bulk_create((
ASN(rir=rir, asn=65001),
ASN(rir=rir, asn=65002),
ASN(rir=rir, asn=65003),
))
data['asns'] = [asn.pk for asn in asns]
serializer = ProviderSerializer(data=data)
self.assertTrue(serializer.is_valid())

View File

@@ -1329,7 +1329,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
)
# Integer filtering
@@ -1435,7 +1435,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://a.example.com',
'cf8': 'http://a.example.com',
'cf9': 'A',
'cf10': ['A', 'X'],
'cf10': ['A', 'B'],
'cf11': manufacturers[0].pk,
'cf12': [manufacturers[0].pk, manufacturers[3].pk],
}),
@@ -1449,7 +1449,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://b.example.com',
'cf8': 'http://b.example.com',
'cf9': 'B',
'cf10': ['B', 'X'],
'cf10': ['B', 'C'],
'cf11': manufacturers[1].pk,
'cf12': [manufacturers[1].pk, manufacturers[3].pk],
}),
@@ -1463,7 +1463,7 @@ class CustomFieldModelFilterTest(TestCase):
'cf7': 'http://c.example.com',
'cf8': 'http://c.example.com',
'cf9': 'C',
'cf10': ['C', 'X'],
'cf10': None,
'cf11': manufacturers[2].pk,
'cf12': [manufacturers[2].pk, manufacturers[3].pk],
}),
@@ -1531,8 +1531,9 @@ class CustomFieldModelFilterTest(TestCase):
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
def test_filter_multiselect(self):
self.assertEqual(self.filterset({'cf_cf10': ['A', 'B']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['X']}, self.queryset).qs.count(), 3)
self.assertEqual(self.filterset({'cf_cf10': ['A']}, self.queryset).qs.count(), 1)
self.assertEqual(self.filterset({'cf_cf10': ['A', 'C']}, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset({'cf_cf10': ['null']}, self.queryset).qs.count(), 1)
def test_filter_object(self):
manufacturer_ids = Manufacturer.objects.values_list('id', flat=True)

View File

@@ -93,19 +93,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
name='Choice Set 3',
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 4',
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"D1,D2,D3"',
'Choice Set 5,"E1,E2,E3"',
'Choice Set 6,"F1,F2,F3"',
'Choice Set 5,"D1,D2,D3"',
'Choice Set 6,"E1,E2,E3"',
'Choice Set 7,"F1,F2,F3"',
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
)
cls.csv_update_data = (
@@ -113,12 +118,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
f'{choice_sets[0].pk},"A,B,C"',
f'{choice_sets[1].pk},"A,B,C"',
f'{choice_sets[2].pk},"A,B,C"',
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
)
cls.bulk_edit_data = {
'description': 'New description',
}
# This is here as extra_choices field splits on colon, but is returned
# from DB as comma separated.
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
if 'extra_choices' in data:
data['extra_choices'] = data['extra_choices'].replace(':', ',')
return super().assertInstanceEqual(instance, data, exclude, api)
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomLink
@@ -457,7 +470,7 @@ class ConfigContextTestCase(
'platforms': [],
'tenant_groups': [],
'tenants': [],
'device_types': [devicetype.id,],
'device_types': [devicetype.id],
'tags': [],
'data': '{"foo": 123}',
}

View File

@@ -1,5 +1,6 @@
from django.core.exceptions import ValidationError
from django.core import validators
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
# NOTE: As this module may be imported by configuration.py, we cannot import
# anything from NetBox itself.
@@ -66,8 +67,7 @@ class CustomValidator:
def __call__(self, instance):
# Validate instance attributes per validation rules
for attr_name, rules in self.validation_rules.items():
assert hasattr(instance, attr_name), f"Invalid attribute '{attr_name}' for {instance.__class__.__name__}"
attr = getattr(instance, attr_name)
attr = self._getattr(instance, attr_name)
for descriptor, value in rules.items():
validator = self.get_validator(descriptor, value)
try:
@@ -79,6 +79,26 @@ class CustomValidator:
# Execute custom validation logic (if any)
self.validate(instance)
@staticmethod
def _getattr(instance, name):
# Attempt to resolve many-to-many fields to their stored values
m2m_fields = [f.name for f in instance._meta.local_many_to_many]
if name in m2m_fields:
if name in getattr(instance, '_m2m_values', []):
return instance._m2m_values[name]
if instance.pk:
return list(getattr(instance, name).all())
return []
# Raise a ValidationError for unknown attributes
if not hasattr(instance, name):
raise ValidationError(_('Invalid attribute "{name}" for {model}').format(
name=name,
model=instance.__class__.__name__
))
return getattr(instance, name)
def get_validator(self, descriptor, value):
"""
Instantiate and return the appropriate validator based on the descriptor given. For

View File

@@ -1073,7 +1073,7 @@ class ReportJobsView(ContentTypePermissionRequiredMixin, View):
jobs = Job.objects.filter(
object_type=object_type,
object_id=module.pk,
name=report.name
name=report.class_name
)
jobs_table = JobTable(

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

@@ -1,3 +1,5 @@
from copy import deepcopy
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.shortcuts import get_object_or_404
@@ -290,7 +292,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
)
# Prepare object data for deserialization
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
# Initialize the serializer with a list or a single object depending on what was requested
serializer_class = get_serializer_for_model(self.queryset.model)

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:
@@ -944,6 +950,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
choices=VLANStatusChoices,
null_value=None
)
available_at_site = django_filters.ModelChoiceFilter(
queryset=Site.objects.all(),
method='get_for_site'
)
available_on_device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
method='get_for_device'
@@ -978,6 +988,10 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass
return queryset.filter(qs_filter)
@extend_schema_field(OpenApiTypes.STR)
def get_for_site(self, queryset, name, value):
return queryset.get_for_site(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value)
@@ -1227,3 +1241,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
)
)
return qs
class PrimaryIPFilterSet(django_filters.FilterSet):
"""
An inheritable FilterSet for models which support primary IP assignment.
"""
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)

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

@@ -300,6 +300,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
parent = forms.CharField(
required=False,
widget=forms.TextInput(
@@ -452,6 +453,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
selector_fields = ('filter_id', 'q', 'site_id')
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -523,6 +525,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Attributes'), ('protocol', 'port')),
(_('Assignment'), ('device_id', 'virtual_machine_id')),
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_('Device'),
)
virtual_machine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_('Virtual Machine'),
)
tag = TagFilterField(model)

View File

@@ -818,7 +818,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
class Meta:
model = L2VPNTermination
fields = ('l2vpn', )
fields = ('l2vpn', 'tags')
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')

View File

@@ -224,11 +224,11 @@ class VLAN(PrimaryModel):
# Validate VLAN group (if assigned)
if self.group and self.site and self.group.scope != self.site:
raise ValidationError({
'group': _(
raise ValidationError(
_(
"VLAN is assigned to group {group} (scope: {scope}); cannot also assign to site {site}."
).format(group=self.group, scope=self.group.scope, site=self.site)
})
)
# Validate group min/max VIDs
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:

View File

@@ -69,6 +69,35 @@ class VLANGroupQuerySet(RestrictedQuerySet):
class VLANQuerySet(RestrictedQuerySet):
def get_for_site(self, site):
"""
Return all VLANs in the specified site
"""
from .models import VLANGroup
q = Q()
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
scope_id=site.pk
)
if site.region:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
scope_id__in=site.region.get_ancestors(include_self=True)
)
if site.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=site.group.get_ancestors(include_self=True)
)
return self.filter(
Q(group__in=VLANGroup.objects.filter(q)) |
Q(site=site) |
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
def get_for_device(self, device):
"""
Return all VLANs available to the specified Device.

View File

@@ -56,8 +56,12 @@ def clear_primary_ip(instance, **kwargs):
"""
field_name = f'primary_ip{instance.family}'
if device := Device.objects.filter(**{field_name: instance}).first():
device.snapshot()
setattr(device, field_name, None)
device.save()
if virtualmachine := VirtualMachine.objects.filter(**{field_name: instance}).first():
virtualmachine.snapshot()
setattr(virtualmachine, field_name, None)
virtualmachine.save()
@@ -67,4 +71,6 @@ def clear_oob_ip(instance, **kwargs):
When an IPAddress is deleted, trigger save() on any Devices for which it was a OOB IP.
"""
if device := Device.objects.filter(oob_ip=instance).first():
device.snapshot()
device.oob_ip = None
device.save()

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

@@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
orderable=False,
verbose_name=_('Object Site')
)
tags = columns.TagColumn(
url_name='ipam:l2vpntermination_list'
)
class Meta(NetBoxTable.Meta):
model = L2VPNTermination
fields = (
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
'actions',
'tags', 'actions',
)
default_columns = (
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',

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]
@@ -1351,6 +1359,7 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLANGroup(name='VLAN Group 1', slug='vlan-group-1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4'),
)
VLANGroup.objects.bulk_create(groups)
@@ -1407,6 +1416,9 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
VLAN(vid=301, name='VLAN 301', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
VLAN(vid=302, name='VLAN 302', site=sites[5], group=groups[23], role=roles[2], tenant=tenants[2], status=VLANStatusChoices.STATUS_RESERVED),
# Create one globally available VLAN on a VLAN group
VLAN(vid=500, name='VLAN Group 1', group=groups[24]),
# Create one globally available VLAN
VLAN(vid=1000, name='Global VLAN'),
)
@@ -1480,12 +1492,17 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_available_on_device(self):
device_id = Device.objects.first().pk
params = {'available_on_device': device_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
def test_available_on_virtualmachine(self):
vm_id = VirtualMachine.objects.first().pk
params = {'available_on_virtualmachine': vm_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) # 5 scoped + 1 global group + 1 global
def test_available_at_site(self):
site_id = Site.objects.first().pk
params = {'available_at_site': site_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) # 4 scoped + 1 global group + 1 global
class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):

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
)
@@ -661,6 +661,26 @@ class IPRangeListView(generic.ObjectListView):
class IPRangeView(generic.ObjectView):
queryset = IPRange.objects.all()
def get_extra_context(self, request, instance):
# Parent prefixes table
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(prefix__net_contains_or_equals=str(instance.start_address.ip)),
Q(prefix__net_contains_or_equals=str(instance.end_address.ip)),
vrf=instance.vrf
).prefetch_related(
'site', 'role', 'tenant', 'vlan', 'role'
)
parent_prefixes_table = tables.PrefixTable(
list(parent_prefixes),
exclude=('vrf', 'utilization'),
orderable=False
)
return {
'parent_prefixes_table': parent_prefixes_table,
}
@register_model_view(IPRange, 'ipaddresses', path='ip-addresses')
class IPRangeIPAddressesView(generic.ObjectChildrenView):
@@ -953,7 +973,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
def prep_table_data(self, request, queryset, parent):
if not get_table_ordering(request, self.table):
return add_available_vlans(parent.get_child_vlans(), parent)
return add_available_vlans(queryset, parent)
return queryset

View File

@@ -23,16 +23,16 @@ class ValidatedModelSerializer(BaseModelSerializer):
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
"""
def validate(self, data):
# Remove custom fields data and tags (if any) prior to model validation
attrs = data.copy()
# Remove custom field data (if any) prior to model validation
attrs.pop('custom_fields', None)
attrs.pop('tags', None)
# Skip ManyToManyFields
for field in self.Meta.model._meta.get_fields():
if isinstance(field, ManyToManyField):
attrs.pop(field.name, None)
m2m_values = {}
for field in self.Meta.model._meta.local_many_to_many:
if field.name in attrs:
m2m_values[field.name] = attrs.pop(field.name)
# Run clean() on an instance of the model
if self.instance is None:
@@ -41,6 +41,7 @@ class ValidatedModelSerializer(BaseModelSerializer):
instance = self.instance
for k, v in attrs.items():
setattr(instance, k, v)
instance._m2m_values = m2m_values
instance.full_clean()
return data

View File

@@ -56,8 +56,15 @@ class BriefModeMixin:
def get_queryset(self):
qs = super().get_queryset()
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
if self.brief:
serializer_class = self.get_serializer_class()
# Clear any annotations for fields not present on the nested serializer
for annotation in list(qs.query.annotations.keys()):
if annotation not in serializer_class().fields:
qs.query.annotations.pop(annotation)
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
return qs

View File

@@ -57,6 +57,17 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
return super().clean()
def _post_clean(self):
"""
Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
"""
self.instance._m2m_values = {}
for field in self.instance._meta.local_many_to_many:
if field.name in self.cleaned_data:
self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
return super()._post_clean()
class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
"""
@@ -145,12 +156,16 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi
model: The model class associated with the form
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
selector_fields: An iterable of names of fields to display by default when rendering the form as
a selector widget
"""
q = forms.CharField(
required=False,
label=_('Search')
)
selector_fields = ('filter_id', 'q')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.6.4'
VERSION = '3.6.8'
# Hostname
HOSTNAME = platform.node()
@@ -502,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

@@ -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,
@@ -553,6 +557,14 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
elif name in form.changed_data:
obj.custom_field_data[cf_name] = customfield.serialize(form.cleaned_data[name])
# Store M2M values for validation
obj._m2m_values = {}
for field in obj._meta.local_many_to_many:
if value := form.cleaned_data.get(field.name):
obj._m2m_values[field.name] = list(value)
elif field.name in nullified_fields:
obj._m2m_values[field.name] = []
obj.full_clean()
obj.save()
updated_objects.append(obj)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -264,6 +264,11 @@ export class APISelect {
switch (this.trigger) {
case 'collapse':
if (collapse !== null) {
// If the element is collapsible but already shown, load the data immediately.
if (collapse.classList.contains('show')) {
Promise.all([this.loadData()]);
}
// If this element is part of a collapsible element, only load the data when the
// collapsible element is shown.
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events

View File

@@ -50,7 +50,7 @@
<tr>
<th scope="row">{% trans "Length" %}</th>
<td>
{% if object.length %}
{% if object.length is not None %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
{% else %}
{{ ''|placeholder }}

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

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

@@ -1,6 +1,7 @@
{% comment %}
Include a hidden field of the same name to ensure that unchecked checkboxes
are always included in the submitted form data.
are always included in the submitted form data. Omit fields names
_selected_action to avoid breaking the admin UI.
{% endcomment %}
<input type="hidden" name="{{ widget.name }}" value="">
{% if widget.name != '_selected_action' %}<input type="hidden" name="{{ widget.name }}" value="">{% endif %}
{% include "django/forms/widgets/input.html" %}

View File

@@ -10,18 +10,18 @@
<div class="list-group list-group-flush">
{% for field in form.visible_fields %}
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 or field.name in form.selector_fields %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
{{ field.label }}
</a>
{% endfor %}
</div>
</div>
<div class="col-9">
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, change, keyup from:#id_q delay:500ms">
<input type="hidden" name="_search" value="true" />
<div class="tab-content p-1">
{% for field in form.visible_fields %}
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
<div class="collapse{% if field.name in form.selector_fields %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
{% endfor %}
</div>
<div class="text-end">

View File

@@ -82,6 +82,11 @@
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}

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

@@ -91,6 +91,19 @@ class ContactAssignmentFilterSet(ChangeLoggedModelFilterSet):
queryset=Contact.objects.all(),
label=_('Contact (ID)'),
)
group_id = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='contact__group',
lookup_expr='in',
label=_('Contact group (ID)'),
)
group = TreeNodeMultipleChoiceFilter(
queryset=ContactGroup.objects.all(),
field_name='contact__group',
lookup_expr='in',
to_field_name='slug',
label=_('Contact group (slug)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContactRole.objects.all(),
label=_('Contact role (ID)'),

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

@@ -1,5 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Manufacturer, Site
from tenancy.filtersets import *
from tenancy.models import *
from utilities.testing import ChangeLoggedFilterSetTests
@@ -192,3 +194,72 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [group[0].slug, group[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ContactAssignment.objects.all()
filterset = ContactAssignmentFilterSet
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
contact_groups = (
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
)
for contactgroup in contact_groups:
contactgroup.save()
contact_roles = (
ContactRole(name='Contact Role 1', slug='contact-role-1'),
ContactRole(name='Contact Role 2', slug='contact-role-2'),
ContactRole(name='Contact Role 3', slug='contact-role-3'),
)
ContactRole.objects.bulk_create(contact_roles)
contacts = (
Contact(name='Contact 1', group=contact_groups[0]),
Contact(name='Contact 2', group=contact_groups[1]),
Contact(name='Contact 3', group=contact_groups[2]),
)
Contact.objects.bulk_create(contacts)
assignments = (
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
ContactAssignment(object=sites[1], contact=contacts[1], role=contact_roles[1]),
ContactAssignment(object=sites[2], contact=contacts[2], role=contact_roles[2]),
ContactAssignment(object=manufacturer, contact=contacts[2], role=contact_roles[2]),
)
ContactAssignment.objects.bulk_create(assignments)
def test_content_type(self):
params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_contact(self):
contacts = Contact.objects.all()[:2]
params = {'contact_id': [contacts[0].pk, contacts[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_group(self):
group = ContactGroup.objects.all()[:2]
params = {'group_id': [group[0].pk, group[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group': [group[0].slug, group[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
role = ContactRole.objects.all()[:2]
params = {'role_id': [role[0].pk, role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

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

@@ -218,6 +218,7 @@ class UserConfig(models.Model):
@receiver(post_save, sender=User)
@receiver(post_save, sender=NetBoxUser)
def create_userconfig(instance, created, raw=False, **kwargs):
"""
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.

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

@@ -1,6 +1,6 @@
from django.apps import apps
from django.db.models import F, Count, OuterRef, Subquery
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import post_delete, post_save, pre_delete
from netbox.registry import registry
from .fields import CounterCacheField
@@ -62,6 +62,12 @@ def post_save_receiver(sender, instance, created, **kwargs):
update_counter(parent_model, new_pk, counter_name, 1)
def pre_delete_receiver(sender, instance, origin, **kwargs):
model = instance._meta.model
if not model.objects.filter(pk=instance.pk).exists():
instance._previously_removed = True
def post_delete_receiver(sender, instance, origin, **kwargs):
"""
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
@@ -71,10 +77,8 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
parent_pk = getattr(instance, field_name, None)
# Decrement the parent's counter by one
if parent_pk is not None:
# 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)
if parent_pk is not None and not hasattr(instance, "_previously_removed"):
update_counter(parent_model, parent_pk, counter_name, -1)
#
@@ -106,6 +110,12 @@ def connect_counters(*models):
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)
pre_delete.connect(
pre_delete_receiver,
sender=to_model,
weak=False,
dispatch_uid=f'{model._meta.label}.{field.name}'
)
post_delete.connect(
post_delete_receiver,
sender=to_model,

View File

@@ -9,6 +9,7 @@ from drf_spectacular.types import OpenApiTypes
__all__ = (
'ContentTypeFilter',
'MACAddressFilter',
'MultiValueArrayFilter',
'MultiValueCharFilter',
'MultiValueDateFilter',
'MultiValueDateTimeFilter',
@@ -85,6 +86,21 @@ class MultiValueTimeFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.TimeField)
@extend_schema_field(OpenApiTypes.STR)
class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
field_class = multivalue_field_factory(forms.CharField)
def __init__(self, *args, lookup_expr='contains', **kwargs):
# Set default lookup_expr to 'contains'
super().__init__(*args, lookup_expr=lookup_expr, **kwargs)
def get_filter_predicate(self, v):
# If filtering for null values, ignore lookup_expr
if v is None:
return {self.field_name: None}
return super().get_filter_predicate(v)
class MACAddressFilter(django_filters.CharFilter):
pass

View File

@@ -43,7 +43,7 @@ class DynamicMultipleChoiceField(forms.MultipleChoiceField):
if data is not None:
self.choices = [
choice for choice in self.choices if choice[0] in data
choice for choice in self.choices if choice[0] and choice[0] in data
]
return bound_field

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

@@ -65,5 +65,5 @@ class ChoicesWidget(forms.Textarea):
if not value:
return None
if type(value) is list:
return '\n'.join([f'{k},{v}' for k, v in value])
return '\n'.join([f'{k}:{v}' for k, v in value])
return value

View File

@@ -1,4 +1,5 @@
from netaddr import IPAddress
from netaddr import AddrFormatError, IPAddress
from urllib.parse import urlparse
__all__ = (
'get_client_ip',
@@ -17,11 +18,18 @@ def get_client_ip(request, additional_headers=()):
)
for header in HTTP_HEADERS:
if header in request.META:
client_ip = request.META[header].split(',')[0]
ip = request.META[header].split(',')[0].strip()
try:
return IPAddress(client_ip)
except ValueError:
raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
return IPAddress(ip)
except AddrFormatError:
# Parse the string with urlparse() to remove port number or any other cruft
ip = urlparse(f'//{ip}').hostname
try:
return IPAddress(ip)
except AddrFormatError:
# We did our best
raise ValueError(f"Invalid IP address set for {header}: {ip}")
# Could not determine the client IP address from request headers
return None

View File

@@ -1,3 +1,3 @@
<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
<a class="btn btn-sm {{ color }} copy-content {{ classes }}" data-clipboard-target="{{ target }}" title="Copy to clipboard">
<i class="mdi mdi-content-copy"></i>
</a>

View File

@@ -87,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
@register.inclusion_tag('builtins/copy_content.html')
def copy_content(target, prefix=None, color='primary'):
def copy_content(target, prefix=None, color='primary', classes=None):
"""
Display a copy button to copy the content of a field.
"""
return {
'target': f'#{prefix or ""}{target}',
'color': f'btn-{color}'
'color': f'btn-{color}',
'classes': classes or '',
}

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

@@ -0,0 +1,28 @@
from django.test import TestCase, RequestFactory
from netaddr import IPAddress
from utilities.request import get_client_ip
class GetClientIPTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
def test_ipv4_address(self):
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1')
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080')
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
def test_ipv6_address(self):
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334')
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]')
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080')
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
def test_invalid_ip_address(self):
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip')
with self.assertRaises(ValueError):
get_client_ip(request)

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,

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