Compare commits

..

85 Commits

Author SHA1 Message Date
Jeremy Stretch
3c91331e16 Merge pull request #12234 from netbox-community/develop
Release v3.4.8
2023-04-12 15:29:09 -04:00
jeremystretch
eef38257b9 Release v3.4.8 2023-04-12 14:06:09 -04:00
Austin de Coup-Crank
bb9a125934 Closes #12040: fix bulk import tab selection 2023-04-12 13:01:04 -04:00
Arthur Hanson
8de252e34e 11432 device field (#11567)
* 11432 make device field on interface read-only on api edit call

* 11432 make device field on interface read-only on api edit call

* 11432 extend serializer change to mixin

* 11432 add readonlydevicemixin to template serializers

* 11432 change subclass ordering

* 11432 fix device_type for template serializers

* 11432 DRY

* 11432 DRY

* 11432 make internal var

* 11432 change to model-level validation

* 11432 fix fk accessor

* Clean up validation error messages

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-04-12 10:25:54 -04:00
Arthur Hanson
9e305c6181 Closes #12207: Establish a permission for creating API tokens on behalf of other users (#12192)
* 11091 add permission to allow user to create api tokens for other users

* 11091 update docs

* 11091 fix for test

* 11091 fix for test

* 11091 test case for invalid token creation

* 11091 add test for permission grant

* Cleanup & fix serializer validation

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-04-12 10:25:06 -04:00
pobradovic08
97ed6439ce Fixes #12227: L2VPN Bulk import not setting Tenant field 2023-04-12 09:24:31 -04:00
jeremystretch
6820796c10 Closes #10414: Enable general purpose image attachments for device types 2023-04-10 16:43:40 -04:00
jeremystretch
4a331b560f Closes #11015: Remove unit from commit rate column header in circuits table 2023-04-10 16:35:21 -04:00
jeremystretch
4c9cf9032c Changelog for #10221, #10600, #11431, #11454 2023-04-10 14:06:36 -04:00
jeremystretch
ada01b39cc #10221: Tweak variable names & error message 2023-04-10 14:03:59 -04:00
Abhimanyu Saharan
b41f8755df Fixes GenericForeignKey validation (#11550)
* added model validation for GenericForeignKey

* added ct_field and fk_field null validation

* applied suggestion
2023-04-10 14:02:32 -04:00
Abhimanyu Saharan
2c07762b7a Added optional user and group on custom field (#12206)
* added group and user model to object_type

* Update netbox/utilities/utils.py

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-10 12:40:13 -04:00
kkthxbye
278f2b173a Fixes #11431 - Disallow changing customfield type after creation (#11449)
* Disallow changing customfield type after creation

* Fix test_api.CustomFieldTest

---------

Co-authored-by: kkthxbye-code <>
2023-04-10 10:13:08 -04:00
jeremystretch
768d6f624e Fixes #12191: Change absolute image path to relative 2023-04-10 09:17:13 -04:00
jeremystretch
1146aaff89 Closes #11453: Display a warning banner when DEBUG is enabled 2023-04-10 09:12:04 -04:00
Arthur Hanson
5a4feb7099 10615 filter cable termination_id with cable_end (#12182)
* 10615 filter cable termination_id with cable_end

* 10615 filter distinct

* 10615 filter distinct
2023-04-07 14:13:58 -04:00
jeremystretch
a6fd0ab09a #12007: Move vlan & vlan_id filter methods to CommonInterfaceFilterSet 2023-04-07 13:58:12 -04:00
jeremystretch
9f71cf79e6 Changelog for #12007, #12118 2023-04-07 11:54:43 -04:00
Abhimanyu Saharan
c26fe266cc Moved interface filterset under common class (#12200)
* moved interface filterset under common class #12007

* lint fix
2023-04-07 10:54:39 -04:00
jeremystretch
085cfc58f4 Fixes #12184: Fix filtered bulk deletion for various models 2023-04-07 10:25:36 -04:00
jeremystretch
63a0ec7a79 Fixes #12190: Fix form layout for plugin textarea fields 2023-04-07 10:03:47 -04:00
jeremystretch
ccfdc216a5 Fixes #12118: Refactor bulk creation logic under _instantiate_components() 2023-04-06 17:01:46 -04:00
Jeremy Stretch
2bf9acfb19 Closes #12193: Clean up tests (#12197)
* Fix skipped API tests

* Remove invalid tests

* Correct logger name
2023-04-06 16:35:27 -04:00
jeremystretch
74d8baea30 Remove NS1 from sponsors list 2023-04-06 14:32:57 -04:00
jeremystretch
f8d40ae824 Changelog for #11746, #12011, #12087 2023-04-05 13:32:18 -04:00
Daniel Sheppard
41c92483a0 #12087 - Fix Bulk Edit update when M2M operations are present. (#12169)
* #12087 - Fix Bulk Edit update when M2M operations are present.

* #12087 - Minor tweaks

* Change .set() to .clear()

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

* #12087 - Update comments

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-05 13:29:32 -04:00
Arthur Hanson
94c2a2e56c 11746 fix delete custom field (#12092)
* 11746 delete custom field

* 11746 use filter instead of exclude
2023-04-04 16:38:48 -04:00
Arthur
0a2ae90411 12011 fix module bay bulk create 2023-04-04 16:34:51 -04:00
jeremystretch
b032742418 Closes #12133: Move any instance mutations inside clean() to save() 2023-04-03 16:26:07 -04:00
jeremystretch
8a684adf66 Changelog for #12074, #12117 2023-04-03 15:38:05 -04:00
Arthur Hanson
bca00cd97a 12117 remove clone from cable (#12130)
* 12117 remove clone from cable

* 12117 remove clone button if no params

* Update clone.html

* Update clone.html

* Update clone.html

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-03 15:27:13 -04:00
Arthur Hanson
2883fa14de Fixes #12074: Move automatic location assignment out of clean()
* 12074 fix full clean

* 12074 move device location setting to save from clean

* 12074 fix set location only if present in rack

* Update base.py

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-04-03 15:22:36 -04:00
jeremystretch
3264636b7a Changelog for #12084, #12095 2023-03-30 17:02:23 -04:00
gdprdatasubect
fbc23424a6 Update models.py
Change default MIME-Type as discussed under #12095
2023-03-30 16:58:41 -04:00
jeremystretch
6f08c4a4be Fixes #11846: Update database creation instructions for PostgreSQL 14+ 2023-03-30 16:35:56 -04:00
jeremystretch
0ac8419005 Fixes #12104: Restore copy-to-clipboard & footer navigation in docs 2023-03-30 16:29:54 -04:00
Arthur Hanson
e467589730 12084 saved filters (#12090)
* 12084 change back saved filter saving

* 12084 doc string

* 12084 add test

* Pass SavedFilter initial parameters as JSON & relocate test

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-30 11:00:40 -04:00
jeremystretch
0330c652bd PRVB 2023-03-28 14:13:39 -04:00
jeremystretch
5a6005cdfa Merge branch 'master' into develop 2023-03-28 14:09:47 -04:00
jeremystretch
4eaba7993f Release v3.4.7 2023-03-28 14:08:04 -04:00
kkthxbye
2840f9d71d Fixes #11991 - Add vdcs to InterfaceImportForm and InterfaceBulkEditForm (#11996)
* Add vdcs to InterfaceImportForm and InterfaceBulkEditForm

* Filter vdcs queryset by device when bulk importing interfaces
2023-03-28 14:08:04 -04:00
jeremystretch
9946ae2981 Update changelog for #11645, #12029, #12038 2023-03-28 14:08:04 -04:00
Abhimanyu Saharan
420ec6791f Updated _schedule_at to use local time when _interval is set (#12006)
* updated _schedule_at to use local time when _interval is set

* updated schedule_at to use local time when interval is set
2023-03-28 14:08:04 -04:00
Arthur
47234f1607 12038 show vc priority with placeholder 2023-03-28 14:08:04 -04:00
Arthur
b058bd9cea 12029 add description to virtual description add 2023-03-28 14:08:04 -04:00
jeremystretch
5b03636c88 Update changelog 2023-03-28 14:08:04 -04:00
jeremystretch
be55bb43ad #12058: Fix initial JSON population 2023-03-28 14:08:04 -04:00
Arthur
293afab730 12058 add clone to config context 2023-03-28 14:08:04 -04:00
Arthur Hanson
6b622fd9bf 11933 saved filters clone of content-types and add m2m field cloning (#12014)
* 11933 saved filters clone of content-types and add m2m field cloning

* Fix JSON rendering

* Add content_types to CustomLink.clone()

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-28 14:08:04 -04:00
Arthur
7280dfacab 12038 fix clone tag 2023-03-28 14:08:04 -04:00
Arthur
4428a446d0 12008 make export templates cloneable 2023-03-28 14:08:04 -04:00
Austin de Coup-Crank
2eedcac383 Fixes #11977: Multiple remote authentication backends (#12012)
* Add suppport for REMOTE_AUTH_BACKEND as iterable

* Closes #11977: Support for multiple auth backends

* Tweak list casting

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-28 14:08:04 -04:00
Arthur
35af1d7b61 12049 fix passsword typo 2023-03-28 14:08:04 -04:00
jeremystretch
1b92958870 Closes #11682: Remove lateral padding from highlighted text 2023-03-28 14:08:04 -04:00
Brian Candler
795669113f Improve error reporting for duplicate CSV column headings
Fixes #11990
2023-03-28 14:08:04 -04:00
kkthxbye-code
de57446f36 Use ssid for the string representation of WirelessLinks if available 2023-03-28 14:08:04 -04:00
kkthxbye-code
3b13cef0c8 Render the parameters column as JSON in SavedFiltersTable 2023-03-28 14:08:04 -04:00
kkthxbye-code
497f3145fa Add parameters to the SavedFilterTable 2023-03-28 14:08:04 -04:00
jeremystretch
f597b76ddc Fixes #11979: Correct URL for tags in route targets list 2023-03-28 14:08:04 -04:00
Daniel W. Anner
ebaac82560 Removed type2-ieee802.3at as per described in #11984 2023-03-28 14:08:04 -04:00
Ryan Merolle
371764fecd Add fieldsets functionality to scripts to allow for form field groupings (#11880)
* update script template

* update docs

* introduce default_fieldset

* correct custom script docs

* default to use fieldsets in scripts

* update scripts docs for new behavior

* Misc cleanup

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-28 14:08:04 -04:00
jeremystretch
f67deb0dea PRVB 2023-03-28 14:08:04 -04:00
jeremystretch
d3c5f1e744 Release v3.4.7 2023-03-28 13:46:43 -04:00
kkthxbye
b12551c64b Fixes #11991 - Add vdcs to InterfaceImportForm and InterfaceBulkEditForm (#11996)
* Add vdcs to InterfaceImportForm and InterfaceBulkEditForm

* Filter vdcs queryset by device when bulk importing interfaces
2023-03-28 13:20:23 -04:00
jeremystretch
9995fad513 Update changelog for #11645, #12029, #12038 2023-03-28 10:21:38 -04:00
Abhimanyu Saharan
1d2335d578 Updated _schedule_at to use local time when _interval is set (#12006)
* updated _schedule_at to use local time when _interval is set

* updated schedule_at to use local time when interval is set
2023-03-28 10:19:18 -04:00
Arthur
ad03061edf 12038 show vc priority with placeholder 2023-03-28 10:13:51 -04:00
Arthur
87eabdbffb 12029 add description to virtual description add 2023-03-28 10:10:05 -04:00
jeremystretch
19787dd21d Update changelog 2023-03-28 10:02:29 -04:00
jeremystretch
7d64e5bc62 #12058: Fix initial JSON population 2023-03-28 10:00:15 -04:00
Arthur
a2c7452f90 12058 add clone to config context 2023-03-28 09:56:06 -04:00
Arthur Hanson
654e32cbbe 11933 saved filters clone of content-types and add m2m field cloning (#12014)
* 11933 saved filters clone of content-types and add m2m field cloning

* Fix JSON rendering

* Add content_types to CustomLink.clone()

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-28 09:53:57 -04:00
Arthur
879aabe2f9 12038 fix clone tag 2023-03-28 09:48:05 -04:00
Arthur
835af32213 12008 make export templates cloneable 2023-03-28 09:45:44 -04:00
Austin de Coup-Crank
571d33e660 Fixes #11977: Multiple remote authentication backends (#12012)
* Add suppport for REMOTE_AUTH_BACKEND as iterable

* Closes #11977: Support for multiple auth backends

* Tweak list casting

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-28 08:44:24 -04:00
Arthur
96eb89a469 12049 fix passsword typo 2023-03-24 20:19:58 -04:00
jeremystretch
8d25d7812c Closes #11682: Remove lateral padding from highlighted text 2023-03-17 16:04:55 -04:00
Brian Candler
a864e8127b Improve error reporting for duplicate CSV column headings
Fixes #11990
2023-03-17 09:21:40 -04:00
kkthxbye-code
fc482ed096 Use ssid for the string representation of WirelessLinks if available 2023-03-17 09:19:07 -04:00
kkthxbye-code
85f40bcbe0 Render the parameters column as JSON in SavedFiltersTable 2023-03-16 09:12:48 -04:00
kkthxbye-code
e7ed280790 Add parameters to the SavedFilterTable 2023-03-16 09:12:48 -04:00
jeremystretch
3d14a79428 Fixes #11979: Correct URL for tags in route targets list 2023-03-16 08:20:52 -04:00
Daniel W. Anner
d93a24d0bb Removed type2-ieee802.3at as per described in #11984 2023-03-16 08:16:49 -04:00
Ryan Merolle
4286d74d44 Add fieldsets functionality to scripts to allow for form field groupings (#11880)
* update script template

* update docs

* introduce default_fieldset

* correct custom script docs

* default to use fieldsets in scripts

* update scripts docs for new behavior

* Misc cleanup

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-03-14 15:50:49 -04:00
jeremystretch
016eff52c0 PRVB 2023-03-13 11:59:57 -04:00
89 changed files with 590 additions and 298 deletions

View File

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

View File

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

View File

@@ -58,8 +58,6 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

View File

@@ -121,7 +121,8 @@ social-auth-core
# Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django
social-auth-app-django
# See https://github.com/python-social-auth/social-app-django/issues/429
social-auth-app-django==5.0.0
# SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite

View File

@@ -18,4 +18,4 @@ interface.
Default: False
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Additionally, enabling this setting disables the debug warning banner in the UI. Set this to `True` **only** if you are actively developing the NetBox code base.

View File

@@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
Default: `'netbox.authentication.RemoteUserBackend'`
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins.
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given.
* `netbox.authentication.RemoteUserBackend`
* `netbox.authentication.LDAPBackend`

View File

@@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
* `PORT` - TCP port to use for the connection (default: `25`)
* `USERNAME` - Username with which to authenticate
* `PASSSWORD` - Password with which to authenticate
* `PASSWORD` - Password with which to authenticate
* `USE_SSL` - Use SSL when connecting to the server (default: `False`)
* `USE_TLS` - Use TLS when connecting to the server (default: `False`)
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)

View File

@@ -79,7 +79,22 @@ A human-friendly description of what your script does.
### `field_order`
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered. Any fields not included in this iterable be listed last.
By default, script variables will be ordered in the form as they are defined in the script. `field_order` may be defined as an iterable of field names to determine the order in which variables are rendered within a default "Script Data" group. Any fields not included in this iterable be listed last. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the form by default for the user.
### `fieldsets`
`fieldsets` may be defined as an iterable of field groups and their field names to determine the order in which variables are group and rendered. Any fields not included in this iterable will not be displayed in the form. If `fieldsets` is defined, `field_order` will be ignored. A fieldset group for "Script Execution Parameters" will be added to the end of the fieldsets by default for the user.
An example fieldset definition is provided below:
```python
class MyScript(Script):
class Meta:
fieldsets = (
('First group', ('field1', 'field2', 'field3')),
('Second group', ('field4', 'field5')),
)
```
### `commit_default`
@@ -302,7 +317,7 @@ Optionally `schedule_at` can be passed in the form data with a datetime string t
Scripts can be run on the CLI by invoking the management command:
```
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
python3 manage.py runscript [--commit] [--loglevel {debug,info,warning,error,critical}] [--data "<data>"] <module>.<script>
```
The required ``<module>.<script>`` argument is the script to run where ``<module>`` is the name of the python file in the ``scripts`` directory without the ``.py`` extension and ``<script>`` is the name of the script class in the ``<module>`` to run.

View File

@@ -132,7 +132,7 @@ Once you have created a report, it will appear in the reports list. Initially, r
!!! note
To run a report, a user must be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in the admin UI as shown below.
![Adding the run action to a permission](/media/admin_ui_run_permission.png)
![Adding the run action to a permission](../media/admin_ui_run_permission.png)
### Via the Web UI

View File

@@ -54,7 +54,7 @@ Within the shell, enter the following commands to create the database and user (
```postgresql
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
GRANT ALL PRIVILEGES ON DATABASE netbox TO netbox;
ALTER DATABASE netbox OWNER TO netbox;
```
!!! danger "Use a strong password"

View File

@@ -584,11 +584,16 @@ Additionally, a token can be set to expire at a specific time. This can be usefu
#### Client IP Restriction
!!! note
This feature was introduced in NetBox v3.3.
Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.)
#### Creating Tokens for Other Users
It is possible to provision authentication tokens for other users via the REST API. To do, so the requesting user must have the `users.grant_token` permission assigned. While all users have inherent permission to create their own tokens, this permission is required to enable the creation of tokens for other users.
![Adding the grant action to a permission](../media/admin_ui_grant_permission.png)
!!! warning "Exercise Caution"
The ability to create tokens on behalf of other users enables the requestor to access the created token. This ability is intended e.g. for the provisioning of tokens by automated services, and should be used with extreme caution to avoid a security compromise.
### Authenticating to the API

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,5 +1,62 @@
# NetBox v3.4
## v3.4.8 (2023-04-12)
### Enhancements
* [#10414](https://github.com/netbox-community/netbox/issues/10414) - Enable general purpose image attachments for device types
* [#10600](https://github.com/netbox-community/netbox/issues/10600) - Allow custom object fields to reference a user or group
* [#11015](https://github.com/netbox-community/netbox/issues/11015) - Remove unit from commit rate column header in circuits table
* [#11431](https://github.com/netbox-community/netbox/issues/11431) - Disallow changing custom field type after creation
* [#11453](https://github.com/netbox-community/netbox/issues/11453) - Display a warning banner when `DEBUG` is enabled
* [#12007](https://github.com/netbox-community/netbox/issues/12007) - Enable filtering of VM Interfaces by assigned VLAN
* [#12095](https://github.com/netbox-community/netbox/issues/12095) - Specify UTF-8 encoding for default export template MIME type
* [#12207](https://github.com/netbox-community/netbox/issues/12207) - Introduce the `grant_token` permission for controlling the creation of API tokens on behalf of other users
### Bug Fixes
* [#10221](https://github.com/netbox-community/netbox/issues/10221) - Validate generic foreign key relations assigned via REST API requests
* [#11432](https://github.com/netbox-community/netbox/issues/11432) - Prevent existing components & component templates from being reassigned to different devices/device types via the REST API
* [#11454](https://github.com/netbox-community/netbox/issues/11454) - Raise validation error if generic foreign key assignment does not specify both object type and ID
* [#11746](https://github.com/netbox-community/netbox/issues/11746) - Fix cleanup of object data when deleting a custom field
* [#12011](https://github.com/netbox-community/netbox/issues/12011) - Fix KeyError exception when attempting to add module bays in bulk
* [#12040](https://github.com/netbox-community/netbox/issues/12040) - Display relevant UI tab upon bulk import validation failure
* [#12074](https://github.com/netbox-community/netbox/issues/12074) - Fix the automatic assignment of racks to devices via the REST API
* [#12084](https://github.com/netbox-community/netbox/issues/12084) - Fix exception when attempting to create a saved filter for applied filters
* [#12087](https://github.com/netbox-community/netbox/issues/12087) - Fix bulk editing of many-to-many relationships
* [#12117](https://github.com/netbox-community/netbox/issues/12117) - Hide clone button for objects with no clonable attributes
* [#12118](https://github.com/netbox-community/netbox/issues/12118) - Fix instantiation of nested inventory item templates when creating a device
* [#12184](https://github.com/netbox-community/netbox/issues/12184) - Fix filtered bulk deletion for various models
* [#12190](https://github.com/netbox-community/netbox/issues/12190) - Fix form layout for plugin textarea fields
* [#12227](https://github.com/netbox-community/netbox/issues/12227) - Fix tenant assignment on bulk import of L2VPNs
---
## v3.4.7 (2023-03-28)
### Enhancements
* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval
* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms
* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set
* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter
* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings
* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces
### Bug Fixes
* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters
* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters
* [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type
* [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list
* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates
* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form
* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority
* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags
* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts
---
## v3.4.6 (2023-03-13)
### Enhancements

View File

@@ -8,6 +8,9 @@ theme:
custom_dir: docs/_theme/
icon:
repo: fontawesome/brands/github
features:
- content.code.copy
- navigation.footer
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
@@ -20,7 +23,8 @@ theme:
icon: material/lightbulb
name: Switch to Light Mode
plugins:
- search
- search:
lang: en
- mkdocstrings:
handlers:
python:

View File

@@ -57,7 +57,9 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
commit_rate = CommitRateColumn()
commit_rate = CommitRateColumn(
verbose_name='Commit Rate'
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:circuit_list'

View File

@@ -196,6 +196,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate(
circuit_count=count_related(Circuit, 'type')
)
filterset = filtersets.CircuitTypeFilterSet
table = tables.CircuitTypeTable

View File

@@ -1137,7 +1137,6 @@ class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_2_8023AZ = 'type2-ieee802.3az'
TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt'
@@ -1152,7 +1151,6 @@ class InterfacePoETypeChoices(ChoiceSet):
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_2_8023AZ, '802.3az (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'),
)

View File

@@ -24,6 +24,7 @@ __all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
'CableTerminationFilterSet',
'CommonInterfaceFilterSet',
'ConsoleConnectionFilterSet',
'ConsolePortFilterSet',
'ConsolePortTemplateFilterSet',
@@ -1321,11 +1322,63 @@ class PowerOutletFilterSet(
fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end']
class CommonInterfaceFilterSet(django_filters.FilterSet):
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')
)
vlan = django_filters.CharFilter(
method='filter_vlan',
label=_('Assigned VID')
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
def filter_vlan_id(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id=value) |
Q(tagged_vlans=value)
)
def filter_vlan(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id__vid=value) |
Q(tagged_vlans__vid=value)
)
class InterfaceFilterSet(
ModularDeviceComponentFilterSet,
NetBoxModelFilterSet,
CabledObjectFilterSet,
PathEndpointFilterSet
PathEndpointFilterSet,
CommonInterfaceFilterSet
):
# Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis
# members
@@ -1370,14 +1423,6 @@ class InterfaceFilterSet(
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label=_('Assigned VLAN')
)
vlan = django_filters.CharFilter(
method='filter_vlan',
label=_('Assigned VID')
)
type = django_filters.MultipleChoiceFilter(
choices=InterfaceTypeChoices,
null_value=None
@@ -1388,17 +1433,6 @@ class InterfaceFilterSet(
rf_channel = django_filters.MultipleChoiceFilter(
choices=WirelessChannelChoices
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
vdc_id = django_filters.ModelMultipleChoiceFilter(
field_name='vdcs',
queryset=VirtualDeviceContext.objects.all(),
@@ -1416,17 +1450,6 @@ class InterfaceFilterSet(
to_field_name='name',
label='Virtual Device Context',
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta:
model = Interface
@@ -1456,24 +1479,6 @@ class InterfaceFilterSet(
except Device.DoesNotExist:
return queryset.none()
def filter_vlan_id(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id=value) |
Q(tagged_vlans=value)
)
def filter_vlan(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id__vid=value) |
Q(tagged_vlans__vid=value)
)
def filter_kind(self, queryset, name, value):
value = value.strip().lower()
return {
@@ -1662,12 +1667,14 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
field_name='terminations__termination_type'
)
termination_a_id = MultiValueNumberFilter(
method='filter_by_cable_end_a',
field_name='terminations__termination_id'
)
termination_b_type = ContentTypeFilter(
field_name='terminations__termination_type'
)
termination_b_id = MultiValueNumberFilter(
method='filter_by_cable_end_b',
field_name='terminations__termination_id'
)
type = django_filters.MultipleChoiceFilter(
@@ -1725,6 +1732,18 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
# Supported objects: device, rack, location, site
return queryset.filter(**{f'terminations___{name}__in': value}).distinct()
def filter_by_cable_end(self, queryset, name, value, side):
# Filter by termination id and cable_end type
return queryset.filter(**{f'{name}__in': value, 'terminations__cable_end': side}).distinct()
def filter_by_cable_end_a(self, queryset, name, value):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_A)
def filter_by_cable_end_b(self, queryset, name, value):
# Filter by termination id and cable_end type
return self.filter_by_cable_end(queryset, name, value, CableEndChoices.SIDE_B)
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()

View File

@@ -103,9 +103,9 @@ class RearPortBulkCreateForm(
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
field_order = ('name', 'label', 'position', 'description', 'tags')
replication_fields = ('name', 'label', 'position')
position_pattern = ExpandableNameField(
position = ExpandableNameField(
label=_('Position'),
required=False,
help_text=_('Alphanumeric ranges are supported. (Must match the number of names being created.)')

View File

@@ -1175,6 +1175,14 @@ class InterfaceBulkEditForm(
},
label=_('LAG')
)
vdcs = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
label='Virtual Device Contexts',
query_params={
'device_id': '$device',
}
)
speed = forms.IntegerField(
required=False,
widget=SelectSpeedWidget(),
@@ -1240,14 +1248,14 @@ class InterfaceBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
)
nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
)

View File

@@ -11,7 +11,9 @@ from dcim.models import *
from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from utilities.forms import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
)
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
@@ -667,6 +669,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Parent LAG interface')
)
vdcs = CSVModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
)
type = CSVChoiceField(
choices=InterfaceTypeChoices,
help_text=_('Physical medium')
@@ -706,7 +714,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
)
@@ -722,6 +730,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data
@@ -730,6 +739,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
else:
return self.cleaned_data['enabled']
def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']:
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
return self.cleaned_data['vdcs']
class FrontPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(

View File

@@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
class Meta:
model = VirtualChassis
fields = [
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
]
def clean(self):

View File

@@ -152,8 +152,6 @@ class Cable(PrimaryModel):
# Validate length and length_unit
if self.length is not None and not self.length_unit:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = ''
if self.pk is None and (not self.a_terminations or not self.b_terminations):
raise ValidationError("Must define A and B terminations when creating a new cable.")
@@ -187,6 +185,10 @@ class Cable(PrimaryModel):
else:
self._abs_length = None
# Clear length_unit if no length is defined
if self.length is None:
self.length_unit = ''
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)

View File

@@ -120,6 +120,12 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original DeviceType ID for reference under clean()
self._original_device_type = self.device_type_id
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
if self.device_type is not None:
@@ -131,6 +137,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
def clean(self):
super().clean()
if self.pk is not None and self._original_device_type != self.device_type_id:
raise ValidationError({
"device_type": "Component templates cannot be moved to a different device type."
})
# A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type:
raise ValidationError(

View File

@@ -78,6 +78,12 @@ class ComponentModel(NetBoxModel):
),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Cache the original Device ID for reference under clean()
self._original_device = self.device_id
def __str__(self):
if self.label:
return f"{self.name} ({self.label})"
@@ -88,6 +94,14 @@ class ComponentModel(NetBoxModel):
objectchange.related_object = self.device
return objectchange
def clean(self):
super().clean()
if self.pk is not None and self._original_device != self.device_id:
raise ValidationError({
"device": "Components cannot be moved to a different device."
})
@property
def parent_object(self):
return self.device
@@ -794,8 +808,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
})
elif self.rf_channel:
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
@@ -803,8 +815,6 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
elif self.rf_channel:
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
# VLAN validation
@@ -815,6 +825,16 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
f"interface's parent device, or it must be global."
})
def save(self, *args, **kwargs):
# Set absolute channel attributes from selected options
if self.rf_channel and not self.rf_channel_frequency:
self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
if self.rf_channel and not self.rf_channel_width:
self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
super().save(*args, **kwargs)
@property
def _occupied(self):
return super()._occupied or bool(self.wireless_link_id)

View File

@@ -120,6 +120,10 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True
)
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
@@ -659,8 +663,6 @@ class Device(PrimaryModel, ConfigContextModel):
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
})
elif self.rack:
self.location = self.rack.location
if self.rack is None:
if self.face:
@@ -776,8 +778,10 @@ class Device(PrimaryModel, ConfigContextModel):
bulk_create: If True, bulk_create() will be called to create all components in a single query
(default). Otherwise, save() will be called on each instance individually.
"""
components = [obj.instantiate(device=self) for obj in queryset]
if components and bulk_create:
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
@@ -790,8 +794,9 @@ class Device(PrimaryModel, ConfigContextModel):
using='default',
update_fields=None
)
elif components:
for component in components:
else:
for obj in queryset:
component = obj.instantiate(device=self)
component.save()
def save(self, *args, **kwargs):
@@ -801,6 +806,9 @@ class Device(PrimaryModel, ConfigContextModel):
if is_new and not self.airflow:
self.airflow = self.device_type.airflow
if self.rack and self.rack.location:
self.location = self.rack.location
super().save(*args, **kwargs)
# If this is a new Device, instantiate all the related components per the DeviceType definition

View File

@@ -222,8 +222,6 @@ class Rack(PrimaryModel, WeightMixin):
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError("Must specify a unit when setting an outer width/depth")
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
@@ -259,6 +257,10 @@ class Rack(PrimaryModel, WeightMixin):
else:
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
if self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
super().save(*args, **kwargs)
@property

View File

@@ -628,6 +628,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate(
rack_count=count_related(Rack, 'role')
)
filterset = filtersets.RackRoleFilterSet
table = tables.RackRoleTable
@@ -909,6 +910,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer')
)
filterset = filtersets.ManufacturerFilterSet
table = tables.ManufacturerTable
@@ -1808,6 +1810,7 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
device_count=count_related(Device, 'device_role'),
vm_count=count_related(VirtualMachine, 'role')
)
filterset = filtersets.DeviceRoleFilterSet
table = tables.DeviceRoleTable
@@ -1868,6 +1871,7 @@ class PlatformBulkEditView(generic.BulkEditView):
class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all()
filterset = filtersets.PlatformFilterSet
table = tables.PlatformTable
@@ -2981,6 +2985,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
class InventoryItemBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItem.objects.all()
filterset = filtersets.InventoryItemFilterSet
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -3038,6 +3043,7 @@ class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate(
inventoryitem_count=count_related(InventoryItem, 'role'),
)
filterset = filtersets.InventoryItemRoleFilterSet
table = tables.InventoryItemRoleTable

View File

@@ -97,6 +97,12 @@ class CustomFieldSerializer(ValidatedModelSerializer):
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
]
def validate_type(self, value):
if self.instance and self.instance.type != value:
raise serializers.ValidationError('Changing the type of custom fields is not supported.')
return value
def get_data_type(self, obj):
types = CustomFieldTypeChoices
if obj.type == types.TYPE_INTEGER:

View File

@@ -1,6 +1,8 @@
import json
from django import forms
from django.db.models import Q
from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
@@ -36,7 +38,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
# TODO: Come up with a canonical way to register suitable models
limit_choices_to=FeatureQuery('webhooks'),
limit_choices_to=FeatureQuery('webhooks').get_query() | Q(app_label='auth', model__in=['user', 'group']),
required=False,
help_text=_("Type of the related object (for object/multi-object fields only)")
)
@@ -63,6 +65,13 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
'ui_visibility': StaticSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable changing the type of a CustomField as it almost universally causes errors if custom field data is already present.
if self.instance.pk:
self.fields['type'].disabled = True
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
@@ -128,11 +137,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs):
# Convert any parameters delivered via initial data to a dictionary
# Convert any parameters delivered via initial data to JSON data
if initial and 'parameters' in initial:
if type(initial['parameters']) is str:
# TODO: Make a utility function for this
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
initial['parameters'] = json.loads(initial['parameters'])
super().__init__(*args, initial=initial, **kwargs)
@@ -254,6 +262,15 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
'tenants', 'tags',
)
def __init__(self, *args, initial=None, **kwargs):
# Convert data delivered via initial data to JSON data
if initial and 'data' in initial:
if type(initial['data']) is str:
initial['data'] = json.loads(initial['data'])
super().__init__(*args, initial=initial, **kwargs)
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):

View File

@@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form):
help_text=_("Interval at which this report is re-run (in minutes)")
)
def clean_schedule_at(self):
def clean(self):
scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < timezone.now():
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time
# When interval is used without schedule at, raise an exception
if self.cleaned_data['interval'] and not scheduled_time:
self.cleaned_data['schedule_at'] = local_now()
return self.cleaned_data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
# When interval is used without schedule at, raise an exception
if self.cleaned_data['_interval'] and not scheduled_time:
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data

View File

@@ -5,7 +5,7 @@ from django.urls import reverse
from extras.querysets import ConfigContextQuerySet
from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from netbox.models.features import CloningMixin, WebhooksMixin
from utilities.utils import deepmerge
@@ -19,7 +19,7 @@ __all__ = (
# Config contexts
#
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
class ConfigContext(CloningMixin, WebhooksMixin, ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@@ -108,6 +108,12 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel):
objects = ConfigContextQuerySet.as_manager()
clone_fields = (
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data',
)
class Meta:
ordering = ['weight', 'name']

View File

@@ -215,7 +215,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
"""
for ct in content_types:
model = ct.model_class()
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
instances = model.objects.filter(custom_field_data__has_key=self.name)
for instance in instances:
del instance.custom_field_data[self.name]
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)

View File

@@ -245,7 +245,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
)
clone_fields = (
'enabled', 'weight', 'group_name', 'button_class', 'new_window',
'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
)
class Meta:
@@ -280,7 +280,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogged
}
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class ExportTemplate(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='export_templates',
@@ -301,7 +301,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
max_length=50,
blank=True,
verbose_name='MIME type',
help_text=_('Defaults to <code>text/plain</code>')
help_text=_('Defaults to <code>text/plain; charset=utf-8</code>')
)
file_extension = models.CharField(
max_length=15,
@@ -313,6 +313,10 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
help_text=_("Download file as attachment")
)
clone_fields = (
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
)
class Meta:
ordering = ('name',)
@@ -353,7 +357,7 @@ class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
Render the template to an HTTP response, delivered as a named file attachment
"""
output = self.render(queryset)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
mime_type = 'text/plain; charset=utf-8' if not self.mime_type else self.mime_type
# Build the response
response = HttpResponse(output, content_type=mime_type)
@@ -406,7 +410,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
parameters = models.JSONField()
clone_fields = (
'enabled', 'weight',
'content_types', 'weight', 'enabled', 'parameters',
)
class Meta:

View File

@@ -5,7 +5,7 @@ from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
from utilities.choices import ColorChoices
from utilities.fields import ColorField
@@ -14,7 +14,7 @@ from utilities.fields import ColorField
# Tags
#
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
class Tag(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField(
primary_key=True
)
@@ -26,6 +26,10 @@ class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
blank=True,
)
clone_fields = (
'color', 'description',
)
class Meta:
ordering = ['name']

View File

@@ -352,6 +352,18 @@ class BaseScript:
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
# Append the default fieldset if defined in the Meta class
default_fieldset = (
('Script Execution Parameters', ('_schedule_at', '_interval', '_commit')),
)
if not hasattr(self.Meta, 'fieldsets'):
fields = (
name for name, _ in self._get_vars().items()
)
self.Meta.fieldsets = (('Script Data', fields),)
self.Meta.fieldsets += default_fieldset
return form
# Logging

View File

@@ -1,3 +1,5 @@
import json
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext as _
@@ -110,11 +112,14 @@ class SavedFilterTable(NetBoxTable):
enabled = columns.BooleanColumn()
shared = columns.BooleanColumn()
def value_parameters(self, value):
return json.dumps(value)
class Meta(NetBoxTable.Meta):
model = SavedFilter
fields = (
'pk', 'id', 'name', 'slug', 'content_types', 'description', 'user', 'weight', 'enabled', 'shared',
'created', 'last_updated',
'created', 'last_updated', 'parameters'
)
default_columns = (
'pk', 'name', 'content_types', 'user', 'description', 'enabled', 'shared',

View File

@@ -1,13 +1,10 @@
import datetime
from unittest import skipIf
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware
from django_rq.queues import get_connection
from rest_framework import status
from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
@@ -16,8 +13,6 @@ from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
rq_worker_running = Worker.count(get_connection('default'))
class AppTest(APITestCase):
@@ -107,6 +102,11 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
update_data = {
'content_types': ['dcim.device'],
'name': 'New_Name',
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
@@ -539,16 +539,6 @@ class ReportTest(APITestCase):
self.assertEqual(response.data['name'], self.TestReport.__name__)
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_report(self):
self.add_permissions('extras.run_script')
url = reverse('extras-api:report-run', kwargs={'pk': None})
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['result']['status']['value'], 'pending')
class ScriptTest(APITestCase):
@@ -589,27 +579,6 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_script(self):
self.add_permissions('extras.run_script')
script_data = {
'var1': 'FooBar',
'var2': 123,
'var3': False,
}
data = {
'data': script_data,
'commit': True,
}
url = reverse('extras-api:script-detail', kwargs={'pk': None})
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['result']['status']['value'], 'pending')
class CreatedUpdatedFilterTest(APITestCase):

View File

@@ -4,6 +4,7 @@ from django.test import TestCase
from dcim.forms import SiteForm
from dcim.models import Site
from extras.choices import CustomFieldTypeChoices
from extras.forms import SavedFilterForm
from extras.models import CustomField
@@ -77,3 +78,24 @@ class CustomFieldModelFormTest(TestCase):
for field_type, _ in CustomFieldTypeChoices.CHOICES:
self.assertIn(field_type, instance.custom_field_data)
self.assertIsNone(instance.custom_field_data[field_type])
class SavedFilterFormTest(TestCase):
def test_basic_submit(self):
"""
Test form submission and validation
"""
form = SavedFilterForm({
'name': 'test-sf',
'slug': 'test-sf',
'content_types': [ContentType.objects.get_for_model(Site).pk],
'weight': 100,
'parameters': {
"status": [
"active"
]
}
})
self.assertTrue(form.is_valid())
form.save()

View File

@@ -414,6 +414,7 @@ class ConfigContextDeleteView(generic.ObjectDeleteView):
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all()
filterset = filtersets.ConfigContextFilterSet
table = tables.ConfigContextTable

View File

@@ -443,7 +443,8 @@ class L2VPNImportForm(NetBoxModelImportForm):
class Meta:
model = L2VPN
fields = ('identifier', 'name', 'slug', 'type', 'description', 'comments', 'tags')
fields = ('identifier', 'name', 'slug', 'tenant', 'type', 'description',
'comments', 'tags')
class L2VPNTerminationImportForm(NetBoxModelImportForm):

View File

@@ -178,9 +178,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
if self.prefix:
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
raise ValidationError({

View File

@@ -62,7 +62,7 @@ class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='ipam:vrf_list'
url_name='ipam:routetarget_list'
)
class Meta(NetBoxTable.Meta):

View File

@@ -836,7 +836,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.add_permissions('ipam.delete_vlan')
url = reverse('ipam-api:vlan-detail', kwargs={'pk': vlan.pk})
with disable_warnings('django.request'):
with disable_warnings('netbox.api.views.ModelViewSet'):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)

View File

@@ -431,6 +431,7 @@ class RoleBulkEditView(generic.BulkEditView):
class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all()
filterset = filtersets.RoleFilterSet
table = tables.RoleTable

View File

@@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.validators import ValidationError
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
@@ -58,6 +59,33 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
class Meta:
abstract = True
def clean(self):
"""
Validate the model for GenericForeignKey fields to ensure that the content type and object ID exist.
"""
super().clean()
for field in self._meta.get_fields():
if isinstance(field, GenericForeignKey):
ct_value = getattr(self, field.ct_field)
fk_value = getattr(self, field.fk_field)
if ct_value is None and fk_value is not None:
raise ValidationError({
field.ct_field: "This field cannot be null.",
})
if fk_value is None and ct_value is not None:
raise ValidationError({
field.fk_field: "This field cannot be null.",
})
if ct_value and fk_value:
klass = getattr(self, field.ct_field).model_class()
if not klass.objects.filter(pk=fk_value).exists():
raise ValidationError({
field.fk_field: f"Related object not found using the provided value: {fk_value}."
})
class PrimaryModel(NetBoxModel):
"""

View File

@@ -1,3 +1,4 @@
import json
from collections import defaultdict
from functools import cached_property
@@ -111,7 +112,11 @@ class CloningMixin(models.Model):
for field_name in getattr(self, 'clone_fields', []):
field = self._meta.get_field(field_name)
field_value = field.value_from_object(self)
if field_value not in (None, ''):
if field_value and isinstance(field, models.ManyToManyField):
attrs[field_name] = [v.pk for v in field_value]
elif field_value and isinstance(field, models.JSONField):
attrs[field_name] = json.dumps(field_value)
elif field_value not in (None, ''):
attrs[field_name] = field_value
# Include tags (if applicable)

View File

@@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.4.6'
VERSION = '3.4.8'
# Hostname
HOSTNAME = platform.node()
@@ -396,8 +396,10 @@ TEMPLATES = [
]
# Set up authentication backends
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
AUTHENTICATION_BACKENDS = [
REMOTE_AUTH_BACKEND,
*REMOTE_AUTH_BACKEND,
'netbox.authentication.ObjectPermissionBackend',
]

View File

@@ -16,7 +16,6 @@ from django_tables2.export import TableExport
from extras.models import ExportTemplate
from extras.signals import clear_webhooks
from utilities.choices import ImportFormatChoices
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
@@ -500,6 +499,21 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
]
nullified_fields = request.POST.getlist('_nullify')
updated_objects = []
model_fields = {}
m2m_fields = {}
# Build list of model fields and m2m fields for later iteration
for name in standard_fields:
try:
model_field = self.queryset.model._meta.get_field(name)
if isinstance(model_field, (ManyToManyField, ManyToManyRel)):
m2m_fields[name] = model_field
else:
model_fields[name] = model_field
except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly
model_fields[name] = None
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
@@ -508,25 +522,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.snapshot()
# Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields:
try:
model_field = self.queryset.model._meta.get_field(name)
except FieldDoesNotExist:
# This form field is used to modify a field rather than set its value directly
model_field = None
for name, model_field in model_fields.items():
# Handle nullification
if name in form.nullable_fields and name in nullified_fields:
if isinstance(model_field, ManyToManyField):
getattr(obj, name).set([])
else:
setattr(obj, name, None if model_field.null else '')
# ManyToManyFields
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name])
setattr(obj, name, None if model_field.null else '')
# Normal fields
elif name in form.changed_data:
setattr(obj, name, form.cleaned_data[name])
@@ -544,6 +543,13 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
obj.save()
updated_objects.append(obj)
# Handle M2M fields after save
for name, m2m_field in m2m_fields.items():
if name in form.nullable_fields and name in nullified_fields:
getattr(obj, name).clear()
else:
getattr(obj, name).set(form.cleaned_data[name])
# Add/remove tags
if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags'])

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -56,4 +56,4 @@
"resolutions": {
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
}
}
}

View File

@@ -42,3 +42,9 @@ input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none !important;
}
// Remove x-axis padding from highlighted text
mark {
padding-left: 0;
padding-right: 0;
}

View File

@@ -70,10 +70,17 @@ Blocks:
</div>
{% endif %}
{% if settings.DEBUG and not settings.DEVELOPER %}
<div class="alert alert-warning text-center mx-3" role="alert">
<strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
Performance may be limited. Debugging should never be enabled on a production system.
</div>
{% endif %}
{% if config.MAINTENANCE_MODE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h4><i class="mdi mdi-alert"></i> Maintenance Mode</h4>
<span>NetBox is currently in maintenance mode. Functionality may be limited.</span>
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
NetBox is currently in maintenance mode. Functionality may be limited.
</div>
{% endif %}

View File

@@ -98,7 +98,7 @@
<div class="card">
<h5 class="card-header text-center">Comments</h5>
<div class="card-body">
{% render_field form.comments %}
{% render_field form.comments %}
</div>
</div>
{% if form.custom_fields %}

View File

@@ -139,7 +139,7 @@
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
</td>
<td>
{{ vc_member.vc_priority|default:"" }}
{{ vc_member.vc_priority|placeholder }}
</td>
</tr>
{% endfor %}

View File

@@ -111,7 +111,6 @@
</div>
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>

View File

@@ -98,6 +98,7 @@
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -85,7 +85,6 @@
{% endif %}
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endblock %}

View File

@@ -8,6 +8,7 @@
</div>
{% render_field form.name %}
{% render_field form.domain %}
{% render_field form.description %}
{% render_field form.tags %}
</div>

View File

@@ -27,7 +27,6 @@
</div>
<div class="field-group my-5">
<h5 class="text-center">Comments</h5>
{% render_field vc_form.comments %}
</div>

View File

@@ -47,16 +47,34 @@
{% csrf_token %}
<div class="field-group my-4">
{% if form.requires_input %}
<div class="row mb-2">
<h5 class="offset-sm-3">Script Data</h5>
</div>
{% if script.Meta.fieldsets %}
{# Render grouped fields according to declared fieldsets #}
{% for group, fields in script.Meta.fieldsets %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5>
</div>
{% for name in fields %}
{% with field=form|getfield:name %}
{% render_field field %}
{% endwith %}
{% endfor %}
</div>
{% endfor %}
{% else %}
{# Render all fields as a single group #}
<div class="row mb-2">
<h5 class="offset-sm-3">Script Data</h5>
</div>
{% render_form form %}
{% endif %}
{% else %}
<div class="alert alert-info">
<i class="mdi mdi-information"></i>
This script does not require any input to run.
</div>
{% render_form form %}
{% endif %}
{% render_form form %}
</div>
<div class="float-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>

View File

@@ -15,12 +15,12 @@ Context:
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
<button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" data-href="#tab_data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
Data Import
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
<button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" data-href="#tab_file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
Upload File
</button>
</li>

View File

@@ -85,7 +85,6 @@ Context:
{% if form.comments %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div>
{% endif %}

View File

@@ -33,9 +33,6 @@
{% endif %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@@ -139,9 +139,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@@ -66,9 +66,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@@ -53,9 +53,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@@ -56,9 +56,6 @@
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="text-center">Comments</h5>
</div>
{% render_field form.comments %}
</div>

View File

@@ -1,39 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="row">
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side A</h5>
</div>
{% render_field form.device_a %}
{% render_field form.interface_a %}
</div>
</div>
<div class="col">
<div class="field-group">
<div class="row mb-2">
<h5 class="offset-sm-3">Side B</h5>
</div>
{% render_field form.device_b %}
{% render_field form.interface_b %}
</div>
</div>
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Comments</h5>
</div>
{% render_field form.comments %}
</div>
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
{% endblock %}

View File

@@ -84,6 +84,7 @@ class TenantGroupBulkDeleteView(generic.BulkDeleteView):
'tenant_count',
cumulative=True
)
filterset = filtersets.TenantGroupFilterSet
table = tables.TenantGroupTable
@@ -247,6 +248,7 @@ class ContactGroupBulkDeleteView(generic.BulkDeleteView):
'contact_count',
cumulative=True
)
filterset = filtersets.ContactGroupFilterSet
table = tables.ContactGroupTable
@@ -305,6 +307,7 @@ class ContactRoleBulkEditView(generic.BulkEditView):
class ContactRoleBulkDeleteView(generic.BulkDeleteView):
queryset = ContactRole.objects.all()
filterset = filtersets.ContactRoleFilterSet
table = tables.ContactRoleTable

View File

@@ -2,6 +2,7 @@ from django.conf import settings
from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
@@ -91,6 +92,16 @@ class TokenSerializer(ValidatedModelSerializer):
data['key'] = Token.generate_key()
return super().to_internal_value(data)
def validate(self, data):
# If the Token is being created on behalf of another user, enforce the grant_token permission.
request = self.context.get('request')
token_user = data.get('user')
if token_user and token_user != request.user and not request.user.has_perm('users.grant_token'):
raise PermissionDenied("This user does not have permission to create tokens for other users.")
return super().validate(data)
class TokenProvisionSerializer(serializers.Serializer):
username = serializers.CharField()

View File

@@ -12,7 +12,7 @@ class AppTest(APITestCase):
def test_root(self):
url = reverse('users-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
response = self.client.get(f'{url}?format=api', **self.header)
self.assertEqual(response.status_code, 200)
@@ -36,14 +36,17 @@ class UserTest(APIViewTestCases.APIViewTestCase):
'password': 'password6',
},
]
bulk_update_data = {
'email': 'test@example.com',
}
@classmethod
def setUpTestData(cls):
users = (
User(username='User_1'),
User(username='User_2'),
User(username='User_3'),
User(username='User_1', password='password1'),
User(username='User_2', password='password2'),
User(username='User_3', password='password3'),
)
User.objects.bulk_create(users)
@@ -74,6 +77,12 @@ class GroupTest(APIViewTestCases.APIViewTestCase):
)
Group.objects.bulk_create(users)
def test_bulk_update_objects(self):
"""
Disabled test. There's no attribute we can set in bulk for Groups.
"""
return
class TokenTest(
# No GraphQL support for Token
@@ -144,6 +153,26 @@ class TokenTest(
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
def test_provision_token_other_user(self):
"""
Test provisioning a Token for a different User with & without the grant_token permission.
"""
self.add_permissions('users.add_token')
user2 = User.objects.create_user(username='testuser2')
data = {
'user': user2.id,
}
url = reverse('users-api:token-list')
# Attempt to create a new Token for User2 *without* the grant_token permission
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 403)
# Assign grant_token permission and successfully create a new Token for User2
self.add_permissions('users.grant_token')
response = self.client.post(url, data, format='json', **self.header)
self.assertEqual(response.status_code, 201)
class ObjectPermissionTest(
# No GraphQL support for ObjectPermission

View File

@@ -34,8 +34,8 @@ class CommentField(forms.CharField):
Markdown</a> syntax is supported
"""
def __init__(self, *, label='', help_text=help_text, required=False, **kwargs):
super().__init__(label=label, help_text=help_text, required=required, **kwargs)
def __init__(self, *, help_text=help_text, required=False, **kwargs):
super().__init__(help_text=help_text, required=required, **kwargs)
class SlugField(forms.SlugField):

View File

@@ -198,8 +198,12 @@ def parse_csv(reader):
header = header.strip()
if '.' in header:
field, to_field = header.split('.', 1)
if field in headers:
raise forms.ValidationError(f'Duplicate or conflicting column header for "{field}"')
headers[field] = to_field
else:
if header in headers:
raise forms.ValidationError(f'Duplicate or conflicting column header for "{header}"')
headers[header] = None
# Parse CSV rows into a list of dictionaries mapped from the column headers.

View File

@@ -1,3 +1,5 @@
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
<i class="mdi mdi-content-copy" aria-hidden="true"></i>&nbsp;Clone
</a>
{% if url %}
<a href="{{ url }}" class="btn btn-sm btn-success" role="button">
<i class="mdi mdi-content-copy" aria-hidden="true"></i> Clone
</a>
{% endif %}

View File

@@ -3,11 +3,8 @@
<div class="row mb-3{% if field.errors %} has-errors{% endif %}">
{# Render the field label, except for: #}
{# 1. Checkboxes (label appears to the right of the field #}
{# 2. Textareas with no label set (will expand across entire row) #}
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %}
{% else %}
{# Render the field label, except for checkboxes #}
{% if field|widget_type != 'checkboxinput' %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }}
</label>

View File

@@ -20,6 +20,8 @@ def clone_button(instance):
param_string = prepare_cloned_fields(instance).urlencode()
if param_string:
url = f'{url}?{param_string}'
else:
url = None
return {
'url': url,

View File

@@ -1,5 +1,6 @@
import datetime
import decimal
import json
from urllib.parse import quote
from typing import Dict, Any
@@ -321,7 +322,7 @@ def applied_filters(context, model, form, query_params):
save_link = None
if user.has_perm('extras.add_savedfilter') and 'filter_id' not in context['request'].GET:
content_type = ContentType.objects.get_for_model(model).pk
parameters = context['request'].GET.urlencode()
parameters = json.dumps(context['request'].GET)
url = reverse('extras:savedfilter_add')
save_link = f"{url}?content_types={content_type}&parameters={quote(parameters)}"

View File

@@ -319,6 +319,22 @@ class CSVDataFieldTest(TestCase):
with self.assertRaises(forms.ValidationError):
self.field.clean(input)
def test_duplicate_header(self):
input = """
status,status
Active,Active
"""
with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
self.field.clean(input)
def test_duplicate_header_key(self):
input = """
vrf.name,vrf.rd
Test VRF,123:456
"""
with self.assertRaisesRegex(forms.ValidationError, 'Duplicate'):
self.field.clean(input)
def test_clean_default_to_field(self):
input = """
address,status,vrf.name

View File

@@ -48,6 +48,10 @@ def get_viewname(model, action=None, rest_api=False):
if is_plugin:
viewname = f'plugins-api:{app_label}-api:{model_name}'
else:
# Alter the app_label for group and user model_name to point to users app
if app_label == 'auth' and model_name in ['group', 'user']:
app_label = 'users'
viewname = f'{app_label}-api:{model_name}'
# Append the action, if any
if action:

View File

@@ -2,9 +2,9 @@ import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from dcim.filtersets import CommonInterfaceFilterSet
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import L2VPN, VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -250,7 +250,7 @@ class VirtualMachineFilterSet(
return queryset.exclude(params)
class VMInterfaceFilterSet(NetBoxModelFilterSet):
class VMInterfaceFilterSet(NetBoxModelFilterSet, CommonInterfaceFilterSet):
cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_machine__cluster',
queryset=Cluster.objects.all(),
@@ -286,28 +286,6 @@ class VMInterfaceFilterSet(NetBoxModelFilterSet):
mac_address = MultiValueMACAddressFilter(
label=_('MAC address'),
)
vrf_id = django_filters.ModelMultipleChoiceFilter(
field_name='vrf',
queryset=VRF.objects.all(),
label=_('VRF'),
)
vrf = django_filters.ModelMultipleChoiceFilter(
field_name='vrf__rd',
queryset=VRF.objects.all(),
to_field_name='rd',
label=_('VRF (RD)'),
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta:
model = VMInterface

View File

@@ -169,8 +169,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
raise ValidationError({
'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
})
elif self.cluster:
self.site = self.cluster.site
# Validate assigned cluster device
if self.device and not self.cluster:
@@ -201,6 +199,14 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
field: f"The specified IP address ({ip}) is not assigned to this VM.",
})
def save(self, *args, **kwargs):
# Assign site from cluster if not set
if self.cluster and not self.site:
self.site = self.cluster.site
super().save(*args, **kwargs)
def get_status_color(self):
return VirtualMachineStatusChoices.colors.get(self.status)

View File

@@ -72,7 +72,7 @@ class VirtualMachineTestCase(TestCase):
# VM with cluster site but no direct site should have its site set automatically
vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
vm.full_clean()
vm.save()
self.assertEqual(vm.site, sites[0])
def test_vm_name_case_sensitivity(self):

View File

@@ -80,6 +80,7 @@ class ClusterTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterType.objects.annotate(
cluster_count=count_related(Cluster, 'type')
)
filterset = filtersets.ClusterTypeFilterSet
table = tables.ClusterTypeTable
@@ -147,6 +148,7 @@ class ClusterGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ClusterGroup.objects.annotate(
cluster_count=count_related(Cluster, 'group')
)
filterset = filtersets.ClusterGroupFilterSet
table = tables.ClusterGroupTable

View File

@@ -190,7 +190,7 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel):
)
def __str__(self):
return f'#{self.pk}'
return self.ssid or f'#{self.pk}'
def get_absolute_url(self):
return reverse('wireless:wirelesslink', args=[self.pk])

View File

@@ -1,8 +1,8 @@
bleach==5.0.1
Django==4.1.7
Django==4.1.8
django-cors-headers==3.14.0
django-debug-toolbar==3.8.1
django-filter==22.1
django-debug-toolbar==4.0.0
django-filter==23.1
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14
django-pglocks==1.0.4
@@ -19,18 +19,18 @@ graphene-django==3.0.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.1.2
mkdocstrings[python-legacy]==0.20.0
mkdocs-material==9.1.6
mkdocstrings[python-legacy]==0.21.2
netaddr==0.8.0
Pillow==9.4.0
psycopg2-binary==2.9.5
Pillow==9.5.0
psycopg2-binary==2.9.6
PyYAML==6.0
sentry-sdk==1.16.0
sentry-sdk==1.19.1
social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0
social-auth-core[openidconnect]==4.4.1
svgwrite==1.4.3
tablib==3.3.0
tzdata==2022.7
tablib==3.4.0
tzdata==2023.3
# Workaround for #7401
jsonschema==3.2.0