Compare commits

..

100 Commits

Author SHA1 Message Date
Jeremy Stretch
b7129e1456 Merge pull request #7984 from netbox-community/develop
Release v3.0.12
2021-12-06 12:07:04 -05:00
jeremystretch
dc6decd404 Release v3.0.12 2021-12-06 11:54:50 -05:00
jeremystretch
40c6b172f7 Fixes #7981: Fix Markdown sanitization regex 2021-12-06 11:33:00 -05:00
thatmattlove
7cb9cedfe1 Fixes #7823: Properly handle return_url when Save & Continue button is present 2021-12-03 16:20:05 -07:00
jeremystretch
97f0414ff3 Changelog for #7751, #7885, #7892 2021-12-03 09:51:05 -05:00
Jeremy Stretch
d5f308d9c9 Merge pull request #7928 from kkthxbye-code/fix-7751
Fix #7751: LDAP: Only get API user from ldap when FIND_GROUP_PERMS is on
2021-12-03 09:48:58 -05:00
Jonathan Senecal
1377eda0ba Add support for L22-30P power port type (#7915)
* Add support for L22-30P power port type

Fixes #7892

* Add support for L22-30R power outlet type
2021-12-03 09:43:42 -05:00
Jeremy Stretch
70259b0d04 Merge pull request #7970 from rhyser9/7885_linkify_vlan_name
Fixes #7885: Linkify VLAN name in VLAN tables
2021-12-03 09:41:28 -05:00
Rhys Barrie
f1466d6da3 netbox-community/netbox#7885: Linkify VLAN name in VLAN tables 2021-12-02 12:27:30 -05:00
jeremystretch
83010e278c Add changelog for #7932, #7941 2021-12-01 09:18:31 -05:00
thatmattlove
dc3040550d Merge branch 'fast-filter' into develop 2021-11-30 10:10:38 -07:00
Daniel Sheppard
09f038f997 Merge pull request #7941 from bluikko/patch-9
Add multistandard ITA power outlet type
2021-11-30 09:37:53 -06:00
bluikko
bbdd3804c7 Add multistandard ITA power outlet type 2021-11-26 10:06:52 +07:00
kkthxbye
a0b9ac7bcc UI: Improve performance of the quick filter 2021-11-25 12:14:07 +01:00
kkthxbye
8bb0cba949 Fix #7751 - LDAP: Only get API user from ldap when FIND_GROUP_PERMS is enabled 2021-11-25 08:09:50 +01:00
jeremystretch
86ada33577 PRVB 2021-11-24 13:58:57 -05:00
Jeremy Stretch
869808b3f9 Merge pull request #7922 from netbox-community/develop
Release v3.0.11
2021-11-24 13:51:17 -05:00
jeremystretch
57ccbf44b8 Release v3.0.11 2021-11-24 13:25:57 -05:00
jeremystretch
416caa8f50 Hide code blocks when not needed 2021-11-24 13:17:59 -05:00
jeremystretch
1e42fecf66 Changelog for #7657 2021-11-24 09:15:30 -05:00
Jeremy Stretch
c9b00891ed Merge pull request #7861 from netbox-community/7657-threadsafe-changelog
Fixes #7657: Make request/webhook caching thread-safe
2021-11-24 09:06:48 -05:00
Jeremy Stretch
497eacbea3 Merge pull request #7898 from ypid/fix/7897
CEE 7/5 exists as power port and power outlet. It is only a power port.
2021-11-22 13:45:08 -05:00
jeremystretch
f90c591c78 Fixes #7890: Correct typo 2021-11-22 13:36:51 -05:00
Robin Schneider
175498940e Fixes #7897: CEE 7/5 is only a power outlet, no power port
Ref:

* https://en.wikipedia.org/wiki/CEE_7_standard_AC_plugs_and_sockets#CEE_7/5_socket_and_CEE_7/6_plug_(French;_Type_E)
* https://blog.packetsar.com/wp-content/uploads/Power_and_Cooling_Cheat_Sheet.pdf
2021-11-21 23:41:36 +01:00
Robin Schneider
eded00cbb3 chore: Always use "CEE 7" (with the space) consistently 2021-11-21 22:23:29 +01:00
jeremystretch
e8d6281007 Changelog for #7399 2021-11-18 10:02:23 -05:00
Jeremy Stretch
8299845615 Merge pull request #7676 from kkthxbye-code/develop
Fix #7399: LDAP excessive CPU usage when AUTH_LDAP_FIND_GROUP_PERMS is enabled
2021-11-18 09:58:34 -05:00
jeremystretch
9ae5865c2d Fixes #7865: REST API should support null values for console port speeds 2021-11-18 09:34:41 -05:00
jeremystretch
c2d0cfdfc0 Fixes #7864: power_port can be null when creating power outlets 2021-11-18 09:27:45 -05:00
Jeremy Stretch
5dd252731e Merge pull request #7863 from etcet/sudo-ln-s
Fixes #7862: Docs: Link housekeeping cron using sudo
2021-11-18 09:16:55 -05:00
Chris James
7b9436d2b9 Docs: Run ln with sudo 2021-11-17 20:33:42 -06:00
jeremystretch
6a369ac985 Closes #7531: Add Markdown support for strikethrough formatting 2021-11-17 16:50:23 -05:00
jeremystretch
23d90823a3 Fixes #7720: Fix initialization of custom script MultiObjectVar field with multiple values 2021-11-17 16:22:47 -05:00
jeremystretch
4bfb6b476c Fixes #7859: Fix styling of form widgets under cable connection views 2021-11-17 15:53:26 -05:00
jeremystretch
0d60099588 Move request object and webhook queue to TLS 2021-11-17 15:12:19 -05:00
jeremystretch
94069e76c9 Fixes #7857: Fix ordering IP addresses by assignment status 2021-11-17 08:51:17 -05:00
jeremystretch
df9d67b873 Fixes #7851: Add missing cluster name filter for virtual machines 2021-11-17 08:48:09 -05:00
jeremystretch
f32e694499 Fixes #7739: Fix exception when tracing cable across circuit with no far end termination 2021-11-15 12:41:57 -05:00
jeremystretch
e5900a3fe3 Correct changelog for #7729 2021-11-15 09:04:18 -05:00
jeremystretch
6e151b044d Changelog for #7229, #7424, #7542 2021-11-15 08:56:03 -05:00
Jeremy Stretch
516bea6a0a Merge pull request #7829 from rhyser9/7542_prefix_vlan_group_column
Fix #7542: Add VLAN Group column to IP Prefix table
2021-11-15 08:54:30 -05:00
Jeremy Stretch
496cabcc53 Merge pull request #7828 from rhyser9/7229_bug_add_vlans_link
Fix #7229: Fix context of VLAN table in VLAN Group view
2021-11-15 08:52:36 -05:00
Jeremy Stretch
d051db5083 Merge pull request #7827 from rhyser9/7424_virtualchassis_id_filter
Fix #7424: Add virtual_chassis_id filter for device components
2021-11-15 08:43:45 -05:00
Rhys Barrie
660fc23e15 netbox-community/netbox#7542: Add VLAN Group column to IP Prefix table 2021-11-13 23:29:26 -05:00
Rhys Barrie
a5a480133f netbox-community/netbox#7229: Fix context of VLAN table in VLAN Group view 2021-11-13 23:08:46 -05:00
Rhys Barrie
68b544c676 netbox-community/netbox#7424: add filterset test for virtual_chassis_id 2021-11-13 22:16:18 -05:00
Rhys Barrie
a8c958ece2 netbox-community/netbox#7424: fix test failure from adding virtual chassis filter field 2021-11-13 22:01:15 -05:00
Rhys Barrie
f77f7ca0ec netbox-community/netbox#7424:make device component device field filter from selected virtual chassis 2021-11-13 21:35:13 -05:00
Rhys Barrie
6b21c8453f netbox-community/netbox#7424: Add virtual_chassis field to device component filter form 2021-11-13 21:33:52 -05:00
Rhys Barrie
fa8a8abc98 netbox-community/netbox#7424: Add virtual_chassis and virtual_chassis_id filter to device components 2021-11-13 21:30:38 -05:00
Jeremy Stretch
98cc36c458 Merge pull request #7824 from netbox-community/2101-q-filters
Closes #2101: Ensure all relevant models have a general purpose search filter
2021-11-12 15:48:19 -05:00
jeremystretch
f3beabba69 Changelog for #2101 2021-11-12 15:33:49 -05:00
jeremystretch
467fa5a847 Add q filters for Token and ObjectPermission filter sets 2021-11-12 15:30:16 -05:00
jeremystretch
50f283cf28 Add q filter for extras models 2021-11-12 15:26:58 -05:00
jeremystretch
f49d7008a0 Add q filters for connection lists 2021-11-12 15:05:33 -05:00
jeremystretch
1fed564c47 Clean up script & report lists 2021-11-12 14:44:14 -05:00
jeremystretch
bb99c3e6f9 Changelog for #7803, #7810 2021-11-12 13:46:06 -05:00
Jeremy Stretch
8820cac792 Merge pull request #7820 from kkthxbye-code/script-reload
Fix #7803: Clear sys.modules cache when reloading scripts
2021-11-12 13:44:42 -05:00
Jeremy Stretch
ada911c20b Merge pull request #7816 from byts-tech/FR7810
Fixes: #7810 Add IEEE 802.15.1 Interface Type
2021-11-12 13:40:41 -05:00
jeremystretch
17e01644f5 Fixes #7813: Fix handling of errors during export template rendering 2021-11-12 13:32:52 -05:00
kkthxbye
9458521f3e Merge branch 'netbox-community:develop' into script-reload 2021-11-12 17:07:11 +01:00
Flo
8aa73c5900 Add IEEE 802.15.1 Interface Type 2021-11-12 16:05:42 +01:00
jeremystretch
c0ca1eaf90 PRVB 2021-11-12 08:54:08 -05:00
Jeremy Stretch
b29a5511df Merge pull request #7815 from netbox-community/develop
Release v3.0.10
2021-11-12 08:50:43 -05:00
jeremystretch
49e77841e0 Release v3.0.10 2021-11-12 08:36:33 -05:00
jeremystretch
daf6c8e327 Fixes #7814: Fix restriction of user & group objects in GraphQL API queries 2021-11-12 08:23:58 -05:00
jeremystretch
9f8068e8d1 Fixes #7808: Fix reference values for content type under custom field import form 2021-11-11 16:21:27 -05:00
jeremystretch
0b705553a5 Fixes #7809: Add missing export template support for various models 2021-11-11 16:16:54 -05:00
jeremystretch
a799094227 Fixes #7788: Improve XSS mitigation in Markdown renderer 2021-11-11 15:38:34 -05:00
jeremystretch
2f064cdfd1 Changelog for #7767 2021-11-11 12:30:28 -05:00
Jeremy Stretch
6c28182dd3 Merge pull request #7767 from CironAkono/FR6925
Fixes: #6925 Interfaces Table - bring back the visual aids from v2.9
2021-11-11 12:29:01 -05:00
jeremystretch
3cb8c5db28 Fixes #7654: Fix assignment of members to virtual chassis with initial position of zero 2021-11-11 12:10:47 -05:00
CironAkono
251abdb4dd Apply suggestions from code review
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2021-11-11 16:36:13 +00:00
jeremystretch
726e4df54b Changelog for #7791 2021-11-11 11:31:51 -05:00
Jeremy Stretch
bd32a6ac8e Merge pull request #7804 from kkthxbye-code/fix-7791
Fix #7791 - Allow devicebay table to be sorted by status
2021-11-11 10:46:15 -05:00
jeremystretch
27d7400c36 Fixes #7802: Differentiate ID and VID columns in VLANs table 2021-11-11 10:23:38 -05:00
kkthxbye-code
53e52aeaa8 Fix sorting devicebay table by status 2021-11-11 14:05:39 +01:00
kkthxbye-code
ae6ed97a80 Clear sys.modules cache when reloading scripts 2021-11-11 11:53:31 +01:00
jeremystretch
3ad773beb3 Fixes #7741: Fix 404 when attaching multiple images in succession 2021-11-09 16:46:58 -05:00
jeremystretch
be91235858 Changelog for #7740 2021-11-09 16:08:11 -05:00
Jeremy Stretch
95fc0bbc94 Merge pull request #7774 from byts-tech/FR7740
Fixes: #7740 Add Mini-DIN 8 Console-Port-Type
2021-11-09 16:06:58 -05:00
jeremystretch
9dad7e4daf Fixes #7701: Fix conflation of assigned IP status & role in interface tables 2021-11-09 16:04:16 -05:00
jeremystretch
d08ed9fe5f Fixes #7780: Preserve mutli-line values during CSV file import 2021-11-09 15:24:21 -05:00
jeremystretch
82210cc116 Changelog for #7783 2021-11-09 15:15:34 -05:00
Jeremy Stretch
94d3e76517 Merge pull request #7785 from jasonyates/develop
Fixes #7783 - Site location visual changes
2021-11-09 15:12:47 -05:00
Jason Yates
3f72492a59 Fixed #7783 - Site location visual changes
Updating site location list to visually match the /dcim/locations list where child locations are "indtended" with mdi-circle-small.

Also removes the padding-left attribute on each row as it is no longer functional.
2021-11-09 15:18:46 +00:00
Flo
b7aa44837f Add Mini-DIN 8 Console-Port-Type 2021-11-08 17:50:13 +01:00
jeremystretch
7b7afd3e7b Changelog for #7765 2021-11-08 08:24:14 -05:00
Nico Domino
9c2514fce4 feat: add outer_width to RackTable (#7766)
* feat: add outer_width to RackTable

* fix: add outer_units to column display

* feat: add outer_depth to available columns
2021-11-08 08:15:26 -05:00
jeremystretch
e04402ed57 Allow bypassing the pre-commit script with NOVALIDATE=1 2021-11-05 13:40:38 -04:00
jeremystretch
3eda8d8482 Closes #7760: Add vid filter field to VLANs list 2021-11-05 13:31:36 -04:00
jeremystretch
79f2f03fb2 Issues policy tweaks 2021-11-05 13:26:18 -04:00
jeremystretch
e5d7578663 Fixes #7750: Fix cable trace image link 2021-11-05 11:10:17 -04:00
jeremystretch
773fd47ca6 Fixes #7752: Fix minimum version check under Python v3.10 2021-11-05 08:45:57 -04:00
kkthxbye-code
830cf4b31f Fix #7399 - LDAP using excessive CPU when AUTH_LDAP_FIND_GROUP_PERMS is enabled 2021-11-05 10:28:30 +01:00
jeremystretch
8f1acb700d Fix ID list creation in API tests 2021-11-04 11:31:39 -04:00
jeremystretch
7b1335825b Closes #7687: Update CentOS installation docs 2021-11-03 10:47:17 -04:00
jeremystretch
11e2200acf PRVB 2021-11-03 09:51:28 -04:00
Miguel Teixeira
b07e88869a Fix interfaces row colors on device interfaces table 2021-10-24 03:31:29 +01:00
Miguel Teixeira
94bd27bcf5 Fix interface icons on the device interfaces table 2021-10-24 03:24:54 +01:00
71 changed files with 765 additions and 380 deletions

View File

@@ -13,11 +13,8 @@ body:
- type: input
attributes:
label: NetBox version
description: >
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.9
description: What version of NetBox are you currently running?
placeholder: v3.0.12
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.0.9
placeholder: v3.0.12
validations:
required: true
- type: dropdown

View File

@@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
* Due to a large backlog of feature requests, we are not currently accepting
any proposals which substantially extend NetBox's functionality beyond its
current feature set. This includes the introduction of any new views or models
which have not already been proposed in an existing feature request.
* Before filing a new feature request, consider raising your idea on the
mailing list first. Feedback you receive there will help validate and shape the
proposed feature before filing a formal issue.
* Before filing a new feature request, consider raising your idea in a
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
first. Feedback you receive there will help validate and shape the proposed
feature before filing a formal issue.
* Good feature requests are very narrowly defined. Be sure to thoroughly
describe the functionality and data model(s) being proposed. The more effort

View File

@@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note

View File

@@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t
---
{!models/dcim/cable.md!}
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:
* Cable 1: Interface 1 to Front Port 1
* Cable 2: Rear Port 1 to Rear Port 2
* Cable 3: Front Port 2 to Interface 2

View File

@@ -21,9 +21,6 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo postgresql-setup --initdb
```
!!! info
PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/).
CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below:
```no-highlight

View File

@@ -17,8 +17,13 @@ Begin by installing all system packages required by NetBox and its dependencies.
=== "CentOS"
!!! warning
CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source.
Once you have Python 3.7 or later installed, install the remaining system packages:
```no-highlight
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
```
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
@@ -262,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -1,6 +1,6 @@
# Installation
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.2. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
The following sections detail how to set up a new instance of NetBox:

View File

@@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also
## Tracing Cables
A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
In the example below, three individual cables comprise a path between devices A and D:
![Cable path](../media/models/dcim_cable_trace.png)
Traced from Interface 1 on Device A, NetBox will show the following path:
* Cable 1: Interface 1 to Front Port 1
* Cable 2: Rear Port 1 to Rear Port 2
* Cable 3: Front Port 2 to Interface 2

View File

@@ -1,5 +1,75 @@
# NetBox v3.0
## v3.0.12 (2021-12-06)
### Enhancements
* [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled
* [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table
* [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types
* [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function
* [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type
### Bug Fixes
* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present
* [#7981](https://github.com/netbox-community/netbox/issues/7981) - Fix Markdown sanitization regex
---
## v3.0.11 (2021-11-24)
### Enhancements
* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
### Bug Fixes
* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
---
## v3.0.10 (2021-11-12)
### Enhancements
* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type
* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list
* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status
### Bug Fixes
* [#7564](https://github.com/netbox-community/netbox/issues/7564) - Fix assignment of members to virtual chassis with initial position of zero
* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables
* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession
* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10
* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table
* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import
* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view
* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer
* [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status
* [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table
* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form
* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models
* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries
---
## v3.0.9 (2021-11-03)
### Enhancements
@@ -396,7 +466,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
### Bug Fixes (from v3.2-beta2)
### Bug Fixes (from v3.0-beta2)
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations

View File

@@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
fields = ['id', 'name', 'url', 'master', 'member_count']
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
#

View File

@@ -356,7 +356,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
required=False
)
power_port = NestedPowerPortTemplateSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
@@ -538,7 +539,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -562,7 +563,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer,
)
speed = ChoiceField(
choices=ConsolePortSpeedChoices,
allow_blank=True,
allow_null=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -585,7 +586,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer,
required=False
)
power_port = NestedPowerPortSerializer(
required=False
required=False,
allow_null=True
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,

View File

@@ -185,6 +185,7 @@ class ConsolePortTypeChoices(ChoiceSet):
TYPE_RJ11 = 'rj-11'
TYPE_RJ12 = 'rj-12'
TYPE_RJ45 = 'rj-45'
TYPE_MINI_DIN_8 = 'mini-din-8'
TYPE_USB_A = 'usb-a'
TYPE_USB_B = 'usb-b'
TYPE_USB_C = 'usb-c'
@@ -202,6 +203,7 @@ class ConsolePortTypeChoices(ChoiceSet):
(TYPE_RJ11, 'RJ-11'),
(TYPE_RJ12, 'RJ-12'),
(TYPE_RJ45, 'RJ-45'),
(TYPE_MINI_DIN_8, 'Mini-DIN 8'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -310,6 +312,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L1560P = 'nema-l15-60p'
TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p'
TYPE_NEMA_L2230P = 'nema-l22-30p'
# California style
TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c'
@@ -415,6 +418,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
(TYPE_NEMA_L2230P, 'NEMA L22-30P'),
)),
('California Style', (
(TYPE_CS6361C, 'CS6361C'),
@@ -426,7 +430,7 @@ class PowerPortTypeChoices(ChoiceSet):
)),
('International/ITA', (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -531,6 +535,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L1560R = 'nema-l15-60r'
TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r'
TYPE_NEMA_L2230R = 'nema-l22-30r'
# California style
TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C'
@@ -550,6 +555,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_ITA_M = 'ita-m'
TYPE_ITA_N = 'ita-n'
TYPE_ITA_O = 'ita-o'
TYPE_ITA_MULTISTANDARD = 'ita-multistandard'
# USB
TYPE_USB_A = 'usb-a'
TYPE_USB_MICROB = 'usb-micro-b'
@@ -628,6 +634,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
(TYPE_NEMA_L2230R, 'NEMA L22-30R'),
)),
('California Style', (
(TYPE_CS6360C, 'CS6360C'),
@@ -638,8 +645,8 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
(TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
(TYPE_ITA_H, 'ITA Type H'),
(TYPE_ITA_I, 'ITA Type I'),
@@ -649,6 +656,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_ITA_M, 'ITA Type M (BS 546)'),
(TYPE_ITA_N, 'ITA Type N'),
(TYPE_ITA_O, 'ITA Type O'),
(TYPE_ITA_MULTISTANDARD, 'ITA Multistandard'),
)),
('USB', (
(TYPE_USB_A, 'USB Type A'),
@@ -737,6 +745,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_80211AC = 'ieee802.11ac'
TYPE_80211AD = 'ieee802.11ad'
TYPE_80211AX = 'ieee802.11ax'
TYPE_802151 = 'ieee802.15.1'
# Cellular
TYPE_GSM = 'gsm'
@@ -848,6 +857,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_80211AC, 'IEEE 802.11ac'),
(TYPE_80211AD, 'IEEE 802.11ad'),
(TYPE_80211AX, 'IEEE 802.11ax'),
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
)
),
(

View File

@@ -861,6 +861,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
label='Virtual Chassis (ID)'
)
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis__name',
queryset=VirtualChassis.objects.all(),
to_field_name='name',
label='Virtual Chassis',
)
tag = TagFilter()
def search(self, queryset, name, value):
@@ -1394,6 +1405,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
#
class ConnectionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = MultiValueNumberFilter(
method='filter_connections',
field_name='device__site_id'
@@ -1416,6 +1431,15 @@ class ConnectionFilterSet(BaseFilterSet):
return queryset
return queryset.filter(**{f'{name}__in': value})
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(device__name__icontains=value) |
Q(cable__label__icontains=value)
)
return queryset.filter(qs_filter)
class ConsoleConnectionFilterSet(ConnectionFilterSet):

View File

@@ -215,8 +215,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm)
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
@@ -277,8 +276,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm):
required=False
)
class Meta:
model = Cable
class Meta(ConnectCableToDeviceForm.Meta):
fields = [
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit', 'tags',

View File

@@ -92,12 +92,19 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
label=_('Location'),
fetch_trigger='open'
)
virtual_chassis_id = DynamicModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
required=False,
label=_('Virtual Chassis'),
fetch_trigger='open'
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id'
},
label=_('Device'),
fetch_trigger='open'
@@ -888,7 +895,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -908,7 +915,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'speed'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -928,7 +935,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerPortTypeChoices,
@@ -943,7 +950,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PowerOutletTypeChoices,
@@ -958,7 +965,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
kind = forms.MultipleChoiceField(
choices=InterfaceKindChoices,
@@ -993,7 +1000,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
model = FrontPort
type = forms.MultipleChoiceField(
@@ -1012,7 +1019,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'type', 'color'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
type = forms.MultipleChoiceField(
choices=PortTypeChoices,
@@ -1030,7 +1037,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
tag = TagFilterField(model)
@@ -1040,7 +1047,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
]
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -1068,6 +1075,11 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1095,6 +1107,11 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
@@ -1122,6 +1139,11 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
label=_('Search')
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,

View File

@@ -117,12 +117,18 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
]
def clean(self):
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member."
})
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Assign VC members
if instance.pk:
initial_position = self.cleaned_data.get('initial_position') or 1
if instance.pk and self.cleaned_data['members']:
initial_position = self.cleaned_data.get('initial_position', 1)
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.virtual_chassis = instance
member.vc_position = i

View File

@@ -442,15 +442,16 @@ class CableTraceSVG:
parent_objects.append(parent_object)
# Near end termination
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
if near_end is not None:
termination = self._draw_box(
width=self.width * .8,
color=self._get_color(near_end),
url=near_end.get_absolute_url(),
labels=self._get_labels(near_end),
y_indent=PADDING,
radius=5
)
terminations.append(termination)
# Connector (either a Cable or attachment to a ProviderNetwork)
if connector is not None:

View File

@@ -53,6 +53,14 @@ def get_cabletermination_row_class(record):
return ''
def get_interface_row_class(record):
if not record.enabled:
return 'danger'
elif record.is_virtual:
return 'primary'
return get_cabletermination_row_class(record)
def get_interface_state_attribute(record):
"""
Get interface enabled state as string to attach to <tr/> DOM element.
@@ -501,8 +509,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
template_code='<i class="mdi mdi-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}drag-horizontal-variant'
'{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}ethernet'
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}reorder-horizontal'
'{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
@@ -534,7 +542,7 @@ class DeviceInterfaceTable(InterfaceTable):
'cable', 'connection', 'actions',
)
row_attrs = {
'class': get_cabletermination_row_class,
'class': get_interface_row_class,
'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute,
}
@@ -653,7 +661,8 @@ class DeviceBayTable(DeviceComponentTable):
}
)
status = tables.TemplateColumn(
template_code=DEVICEBAY_STATUS
template_code=DEVICEBAY_STATUS,
order_by=Accessor('installed_device__status')
)
installed_device = tables.Column(
linkify=True

View File

@@ -72,12 +72,20 @@ class RackTable(BaseTable):
tags = TagColumn(
url_name='dcim:rack_list'
)
outer_width = tables.TemplateColumn(
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
verbose_name='Outer Width'
)
outer_depth = tables.TemplateColumn(
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name='Outer Depth'
)
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@@ -40,17 +40,13 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
<div class="table-badge-group">
{% for ip in record.ip_addresses.all %}
<a
class="table-badge{% if ip.status != 'active' %} badge bg-{{ ip.get_status_class }}{% elif ip.role %} badge bg-{{ ip.get_role_class }}{% endif %}"
href="{{ ip.get_absolute_url }}"
{% if ip.status != 'active'%}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}"
{% elif ip.role %}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_role_display }}"
{% endif %}
>
{{ ip }}
</a>
{% endfor %}
{% for ip in record.ip_addresses.all %}
{% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_class }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
{% else %}
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
{% endif %}
{% endfor %}
</div>
"""

View File

@@ -584,6 +584,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
power_port_templates = (
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
)
PowerPortTemplate.objects.bulk_create(power_port_templates)
power_outlet_templates = (
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
@@ -595,14 +601,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 4',
'power_port': power_port_templates[0].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 5',
'power_port': power_port_templates[1].pk,
},
{
'device_type': devicetype.pk,
'name': 'Power Outlet Template 6',
'power_port': None,
},
]
@@ -1033,14 +1042,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Console Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Port 6',
'speed': None,
},
]
@@ -1072,14 +1084,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
{
'device': device.pk,
'name': 'Console Server Port 4',
'speed': 9600,
},
{
'device': device.pk,
'name': 'Console Server Port 5',
'speed': 115200,
},
{
'device': device.pk,
'name': 'Console Server Port 6',
'speed': None,
},
]
@@ -1139,6 +1154,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
power_ports = (
PowerPort(device=device, name='Power Port 1'),
PowerPort(device=device, name='Power Port 2'),
)
PowerPort.objects.bulk_create(power_ports)
power_outlets = (
PowerOutlet(device=device, name='Power Outlet 1'),
PowerOutlet(device=device, name='Power Outlet 2'),
@@ -1150,14 +1171,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
{
'device': device.pk,
'name': 'Power Outlet 4',
'power_port': power_ports[0].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 5',
'power_port': power_ports[1].pk,
},
{
'device': device.pk,
'name': 'Power Outlet 6',
'power_port': None,
},
]
@@ -1524,7 +1548,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):

View File

@@ -2048,6 +2048,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Device.objects.bulk_create(devices)
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
interfaces = (
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
@@ -2157,6 +2162,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@@ -2,8 +2,9 @@ from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
from utilities.utils import curry
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from .webhooks import flush_webhooks
@@ -15,12 +16,8 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
webhook_queue = []
# Curry signals receivers to pass the current request
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
set_request(request)
thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
@@ -38,5 +35,8 @@ def change_logging(request):
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
flush_webhooks(webhook_queue)
del webhook_queue
flush_webhooks(thread_locals.webhook_queue)
del thread_locals.webhook_queue
# Clear the request from thread-local storage
set_request(None)

View File

@@ -35,6 +35,10 @@ EXACT_FILTER_TYPES = (
class WebhookFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
http_method = django_filters.MultipleChoiceFilter(
choices=WebhookHttpMethodChoices
@@ -47,30 +51,81 @@ class WebhookFilterSet(BaseFilterSet):
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(payload_url__icontains=value)
)
class CustomFieldFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
content_types = ContentTypeFilter()
class Meta:
model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(label__icontains=value) |
Q(description__icontains=value)
)
class CustomLinkFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = CustomLink
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(link_text__icontains=value) |
Q(link_url__icontains=value) |
Q(group_name__icontains=value)
)
class ExportTemplateFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ImageAttachmentFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFilter()
content_type = ContentTypeFilter()
@@ -78,6 +133,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
model = ImageAttachment
fields = ['id', 'content_type_id', 'object_id', 'name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(name__icontains=value)
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(

View File

@@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links')
limit_choices_to=FeatureQuery('export_templates')
)
class Meta:

View File

@@ -31,7 +31,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,

View File

@@ -9,7 +9,7 @@ from django.db import models
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format, time_format
from django.utils.formats import date_format
from rest_framework.utils.encoders import JSONEncoder
from extras.choices import *
@@ -36,7 +36,7 @@ __all__ = (
# Webhooks
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class Webhook(ChangeLoggedModel):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
@@ -175,7 +175,7 @@ class Webhook(ChangeLoggedModel):
# Custom links
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class CustomLink(ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
@@ -234,7 +234,7 @@ class CustomLink(ChangeLoggedModel):
# Export templates
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey(
to=ContentType,
@@ -357,6 +357,8 @@ class ImageAttachment(BigIDModel):
objects = RestrictedQuerySet.as_manager()
clone_fields = ('content_type', 'object_id')
class Meta:
ordering = ('name', 'pk') # name may be non-unique

View File

@@ -14,7 +14,7 @@ from utilities.querysets import RestrictedQuerySet
# Tags
#
@extras_features('webhooks')
@extras_features('webhooks', 'export_templates')
class Tag(ChangeLoggedModel, TagBase):
color = ColorField(
default=ColorChoices.COLOR_GREY

View File

@@ -3,6 +3,7 @@ import json
import logging
import os
import pkgutil
import sys
import traceback
from collections import OrderedDict
@@ -477,6 +478,10 @@ def get_scripts(use_names=False):
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Remove cached module to ensure consistency with filesystem
if module_name in sys.modules:
del sys.modules[module_name]
module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'):
module_name = module.name

View File

@@ -6,6 +6,8 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
from netbox import thread_locals
from netbox.request_context import get_request
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import CustomField, ObjectChange
@@ -20,10 +22,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
m2m_changed = False
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
@@ -31,11 +39,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
request.id == webhook_data['request_id']
)
if not hasattr(instance, 'to_objectchange'):
return
m2m_changed = False
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
@@ -65,6 +68,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
@@ -79,13 +83,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
@@ -94,19 +100,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
objectchange.save()
# Enqueue webhooks
webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue = thread_locals.webhook_queue
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()

View File

@@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic
from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables
from .choices import JobResultStatusChoices
@@ -475,11 +475,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
def alter_obj(self, instance, request, args, kwargs):
if not instance.pk:
# Assign the parent object based on URL kwargs
try:
app_label, model = request.GET.get('content_type').split('.')
except (AttributeError, ValueError):
raise Http404("Content type not specified")
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
return instance
@@ -758,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
def get(self, request, module, name):
script = self._get_script(name, module)
form = script.as_form(initial=request.GET)
form = script.as_form(initial=normalize_querydict(request.GET))
# Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')

View File

@@ -1,3 +1,4 @@
import django_filters
from django import forms
from django.utils.translation import gettext as _
@@ -409,7 +410,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
field_groups = [
['q', 'tag'],
['region_id', 'site_group_id', 'site_id'],
['group_id', 'status', 'role_id'],
['group_id', 'status', 'role_id', 'vid'],
['tenant_group_id', 'tenant_id'],
]
q = forms.CharField(
@@ -461,6 +462,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
label=_('Role'),
fetch_trigger='open'
)
vid = forms.IntegerField(
required=False,
label='VLAN ID'
)
tag = TagFilterField(model)

View File

@@ -206,6 +206,11 @@ class PrefixTable(BaseTable):
site = tables.Column(
linkify=True
)
vlan_group = tables.Column(
accessor='vlan__group',
linkify=True,
verbose_name='VLAN Group'
)
vlan = tables.Column(
linkify=True,
verbose_name='VLAN'
@@ -230,8 +235,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
'is_pool', 'mark_utilized', 'description', 'tags',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@@ -318,7 +323,7 @@ class IPAddressTable(BaseTable):
verbose_name='NAT (Inside)'
)
assigned = BooleanColumn(
accessor='assigned_object',
accessor='assigned_object_id',
linkify=True,
verbose_name='Assigned'
)

View File

@@ -93,7 +93,10 @@ class VLANTable(BaseTable):
pk = ToggleColumn()
vid = tables.TemplateColumn(
template_code=VLAN_LINK,
verbose_name='ID'
verbose_name='VID'
)
name = tables.Column(
linkify=True
)
site = tables.Column(
linkify=True

View File

@@ -0,0 +1,3 @@
import threading
thread_locals = threading.local()

View File

@@ -29,10 +29,13 @@ class TokenAuthentication(authentication.TokenAuthentication):
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend()
user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user
if user:
return user, token
# Load from LDAP if FIND_GROUP_PERMS is active
if ldap_backend.settings.FIND_GROUP_PERMS:
user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user
if user:
return user, token
return token.user, token

View File

@@ -34,7 +34,7 @@ class ObjectPermissionMixin():
object_permissions = ObjectPermission.objects.filter(
self.get_permission_filter(user_obj),
enabled=True
).prefetch_related('object_types')
).order_by('id').distinct('id').prefetch_related('object_types')
# Create a dictionary mapping permissions to their constraints
perms = defaultdict(list)

View File

@@ -1,10 +1,10 @@
import logging
import uuid
from urllib import parse
import logging
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.contrib import auth
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.http import Http404, HttpResponseRedirect
@@ -114,7 +114,7 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
return groups
class ObjectChangeMiddleware(object):
class ObjectChangeMiddleware:
"""
This middleware performs three functions in response to an object being created, updated, or deleted:

View File

@@ -0,0 +1,9 @@
from netbox import thread_locals
def set_request(request):
thread_locals.request = request
def get_request():
return getattr(thread_locals, 'request', None)

View File

@@ -4,6 +4,7 @@ import os
import platform
import re
import socket
import sys
import warnings
from urllib.parse import urlsplit
@@ -16,7 +17,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.9'
VERSION = '3.0.12'
# Hostname
HOSTNAME = platform.node()
@@ -25,7 +26,7 @@ HOSTNAME = platform.node()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Validate Python version
if platform.python_version_tuple() < ('3', '7'):
if sys.version_info < (3, 7):
raise RuntimeError(
f"NetBox requires Python 3.7 or higher (current: Python {platform.python_version()})"
)

View File

@@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view')
def get_table(self, request, permissions):
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
return table
def export_yaml(self):
"""
Export the queryset of objects as concatenated YAML documents.
@@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
)
def get(self, request):
def export_template(self, template, request):
"""
Render an ExportTemplate using the current queryset.
:param template: ExportTemplate instance
:param request: The current request
"""
try:
return template.render_to_response(self.queryset)
except Exception as e:
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
return redirect(request.path)
def get(self, request):
model = self.queryset.model
content_type = ContentType.objects.get_for_model(model)
@@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Export template/YAML rendering
if 'export' in request.GET and request.GET['export'] != 'table':
if 'export' in request.GET:
# An export template has been specified
if request.GET['export']:
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
try:
return et.render_to_response(self.queryset)
except Exception as e:
messages.error(
request,
"There was an error rendering the selected export template ({}): {}".format(
et.name, e
)
)
# Export the current table view
if request.GET['export'] == 'table':
table = self.get_table(request, permissions)
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
# Check for YAML export support
# Render an ExportTemplate
elif request.GET['export']:
template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
return self.export_template(template, request)
# Check for YAML export support on the model
elif hasattr(model, 'to_yaml'):
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
return response
# Construct the objects table
table = self.table(self.queryset, user=request.user)
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
table.columns.show('pk')
# Fall back to default table/YAML export
else:
table = self.get_table(request, permissions)
return self.export_table(table)
# Handle table-based exports (current view or static CSV-based)
if request.GET.get('export') == 'table':
columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns)
elif 'export' in request.GET:
return self.export_table(table)
# Paginate the objects table
# Render the objects table
table = self.get_table(request, permissions)
paginate_table(table, request)
context = {

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,32 @@
import { getElements, scrollTo } from '../util';
import { getElements, scrollTo, isTruthy } from '../util';
/**
* When editing an object, it is sometimes desirable to customize the form action *without*
* overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want
* to use the `formaction` attribute on that element because it will be included on the form even
* if the button isn't clicked.
*
* @example
* ```html
* <button type="button" return-url="/special-url/">
* Save & Continue
* </button>
* ```
*
* @param event Click event.
*/
function handleSubmitWithReturnUrl(event: MouseEvent): void {
const element = event.target as HTMLElement;
if (element.tagName === 'BUTTON') {
const button = element as HTMLButtonElement;
const action = button.getAttribute('return-url');
const form = button.form;
if (form !== null && isTruthy(action)) {
form.action = action;
form.submit();
}
}
}
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
// Track the names of each invalid field.
@@ -38,6 +66,15 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
}
}
/**
* Attach event listeners to form buttons with the `return-url` attribute present.
*/
function initReturnUrlSubmitButtons(): void {
for (const button of getElements<HTMLButtonElement>('button[return-url]')) {
button.addEventListener('click', handleSubmitWithReturnUrl);
}
}
/**
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
@@ -54,4 +91,5 @@ export function initFormElements(): void {
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
}
}
initReturnUrlSubmitButtons();
}

View File

@@ -107,6 +107,9 @@ function initTableFilter(): void {
// Create a regex pattern from the input search text to match against.
const filter = new RegExp(target.value.toLowerCase().trim());
// List of which rows which match the query
const matchedRows: Array<HTMLTableRowElement> = [];
for (const row of rows) {
// Find the row's checkbox and deselect it, so that it is not accidentally included in form
// submissions.
@@ -114,19 +117,26 @@ function initTableFilter(): void {
if (checkBox !== null) {
checkBox.checked = false;
}
// Iterate through each row's cell values
for (const value of getRowValues(row)) {
if (filter.test(value.toLowerCase())) {
// If this row matches the search pattern, but is already hidden, unhide it and stop
// iterating through the rest of the cells.
row.classList.remove('d-none');
// If this row matches the search pattern, add it to the list.
matchedRows.push(row);
break;
} else {
// If none of the cells in this row match the search pattern, hide the row.
row.classList.add('d-none');
}
}
}
// Iterate the rows again to set visibility.
// This results in a single reflow instead of one for each row.
for (const row of rows) {
if (matchedRows.indexOf(row) >= 0) {
row.classList.remove('d-none');
} else {
row.classList.add('d-none');
}
}
}
input.addEventListener('keyup', debounce(handleInput, 300));
}

View File

@@ -814,7 +814,7 @@ table .table-badge-group {
}
&.badge:not(:last-of-type):not(:only-child) {
margin-bottom: map.get($spacers, 2);
margin-bottom: map.get($spacers, 1);
}
}
}

View File

@@ -51,7 +51,7 @@
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if obj.pk %}
<button type="submit" formaction="?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="button" return-url="?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>

View File

@@ -219,8 +219,8 @@
</tr>
{% for location in locations %}
<tr>
<td style="padding-left: {{ location.level }}8px">
<i class="mdi mdi-folder-open"></i>
<td>
{% for i in location.level|as_range %}<i class="mdi mdi-circle-small"></i>{% endfor %}
<a href="{{ location.get_absolute_url }}">{{ location }}</a>
</td>
<td>

View File

@@ -61,7 +61,7 @@
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
</td>
<td>
{% badge vc_member.vc_position %}
{% badge vc_member.vc_position show_empty=True %}
</td>
<td>
{% if object.master == vc_member %}

View File

@@ -3,108 +3,94 @@
{% block title %}Reports{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if reports %}
{% for module, module_reports in reports %}
<div class="card">
<h5 class="card-header"><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
<th></th>
</tr>
</thead>
<tbody>
{% for report in module_reports %}
<tr>
<td>
<a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=report.result %}
</td>
<td>{{ report.description|render_markdown|placeholder }}</td>
<td class="text-end">
{% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</td>
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% for method, stats in report.result.data.items %}
<tr>
<td colspan="4" class="method">
{{ method }}
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">Reports</a>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% if reports %}
{% for module, module_reports in reports %}
<div class="card">
<h5 class="card-header">
<a name="module.{{ module }}"></a>
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th width="110">Status</th>
<th>Description</th>
<th width="150" class="text-end">Last Run</th>
<th width="120"></th>
</tr>
</thead>
<tbody>
{% for report in module_reports %}
<tr>
<td>
<a href="{% url 'extras:report' module=report.module name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=report.result %}
</td>
<td>{{ report.description|render_markdown|placeholder }}</td>
<td class="text-end">
{% if report.result %}
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
{% else %}
<span class="text-muted">Never</span>
{% endif %}
</td>
<td>
{% if perms.extras.run_report %}
<div class="float-end noprint">
<form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary btn-sm" style="width: 110px">
{% if report.result %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
</div>
{% endif %}
</td>
</tr>
{% for method, stats in report.result.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endfor %}
{% else %}
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">No Reports Found</h4>
Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
<hr/>
<small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
</div>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col col-md-3">
{% if reports %}
<div class="card">
<div class="card-body">
{% for module, module_reports in reports %}
<h5>{{ module|bettertitle }}</h5>
<div class="small mb-2">
<ul class="list-group list-group-flush">
{% for report in module_reports %}
<a href="#{{ report.module }}.{{ report.class_name }}" class="list-group-item">
<i class="mdi mdi-file-chart-outline"></i> {{ report.name }}
<div class="float-end">
{% include 'extras/inc/job_label.html' with result=report.result %}
</div>
</a>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% endfor %}
{% else %}
<div class="alert alert-info" role="alert">
<h4 class="alert-heading">No Reports Found</h4>
Reports should be saved to <code>{{ settings.REPORTS_ROOT }}</code>.
<hr/>
<small>This path can be changed by setting <code>REPORTS_ROOT</code> in NetBox's configuration.</small>
</div>
{% endif %}
</div>
{% endblock content-wrapper %}

View File

@@ -3,74 +3,66 @@
{% block title %}Scripts{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-9">
{% if scripts %}
{% for module, module_scripts in scripts.items %}
<h3><a name="module.{{ module }}"></a>{{ module|bettertitle }}</h3>
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
</tr>
</thead>
<tbody>
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=script.result %}
</td>
<td>{{ script.Meta.description|render_markdown }}</td>
{% if script.result %}
<td class="text-end">
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
</td>
{% else %}
<td class="text-end text-muted">Never</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">Scripts</a>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% if scripts %}
{% for module, module_scripts in scripts.items %}
<div class="card">
<h5 class="card-header">
<a name="module.{{ module }}"></a>
<i class="mdi mdi-file-document-outline"></i> {{ module|bettertitle }}
</h5>
<div class="card-body">
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th width="110">Status</th>
<th>Description</th>
<th class="text-end">Last Run</th>
</tr>
</thead>
<tbody>
{% for class_name, script in module_scripts.items %}
<tr>
<td>
<a href="{% url 'extras:script' module=script.module name=class_name %}" name="script.{{ class_name }}">{{ script }}</a>
</td>
<td>
{% include 'extras/inc/job_label.html' with result=script.result %}
</td>
<td>
{{ script.Meta.description|render_markdown|placeholder }}
</td>
{% if script.result %}
<td class="text-end">
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
</td>
{% else %}
<td class="text-end text-muted">Never</td>
{% endif %}
</tr>
{% endfor %}
{% else %}
<div class="alert alert-info">
<h4 class="alert-heading">No Scripts Found</h4>
Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
<hr/>
This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
</div>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="col col-md-3">
{% if scripts %}
<div class="card">
<div class="card-body">
{% for module, module_scripts in scripts.items %}
<h5>{{ module|bettertitle }}</h5>
<div class="small mb-2">
<ul class="list-group list-group-flush">
{% for class_name, script in module_scripts.items %}
<a href="#script.{{ class_name }}" class="list-group-item">
<i class="mdi mdi-file-chart-outline"></i> {{ script.name }}
<div class="float-end">
{% include 'extras/inc/job_label.html' with result=script.result %}
</div>
</a>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% endfor %}
{% else %}
<div class="alert alert-info">
<h4 class="alert-heading">No Scripts Found</h4>
Scripts should be saved to <code>{{ settings.SCRIPTS_ROOT }}</code>.
<hr/>
This path can be changed by setting <code>SCRIPTS_ROOT</code> in NetBox's configuration.
</div>
{% endif %}
</div>
{% endblock content-wrapper %}

View File

@@ -137,7 +137,11 @@
Additional Headers
</h5>
<div class="card-body">
<pre>{{ object.additional_headers }}</pre>
{% if object.additional_headers %}
<pre>{{ object.additional_headers }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
<div class="card">
@@ -145,7 +149,11 @@
Body Template
</h5>
<div class="card-body">
<pre>{{ object.body_template }}</pre>
{% if object.body_template %}
<pre>{{ object.body_template }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% plugin_right_page object %}

View File

@@ -44,7 +44,7 @@
</div>
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Attach an image
</a>
</div>

View File

@@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
@@ -68,7 +69,7 @@
VLANs
</h5>
<div class="card-body">
{% include 'inc/table.html' with table=vlans_table %}
{% render_table vlans_table 'inc/table.html' %}
</div>
{% if perms.ipam.add_vlan %}
<div class="card-footer text-end noprint">

View File

@@ -46,7 +46,7 @@
{% block buttons %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% if obj.pk %}
<button type="submit" formaction="?return_url={% url 'virtualization:vminterface_edit' pk=obj.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="button" return-url="?return_url={% url 'virtualization:vminterface_edit' pk=obj.pk %}" class="btn btn-outline-primary">Save & Continue Editing</button>
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">Create & Add Another</button>

View File

@@ -99,8 +99,20 @@ class TokenFilterSet(BaseFilterSet):
model = Token
fields = ['id', 'key', 'write_enabled']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user__username__icontains=value) |
Q(description__icontains=value)
)
class ObjectPermissionFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users',
queryset=User.objects.all(),
@@ -127,3 +139,11 @@ class ObjectPermissionFilterSet(BaseFilterSet):
class Meta:
model = ObjectPermission
fields = ['id', 'name', 'enabled', 'object_types']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)

View File

@@ -19,7 +19,7 @@ class GroupType(DjangoObjectType):
@classmethod
def get_queryset(cls, queryset, info):
return RestrictedQuerySet(model=Group)
return RestrictedQuerySet(model=Group).restrict(info.context.user, 'view')
class UserType(DjangoObjectType):
@@ -34,4 +34,4 @@ class UserType(DjangoObjectType):
@classmethod
def get_queryset(cls, queryset, info):
return RestrictedQuerySet(model=User)
return RestrictedQuerySet(model=User).restrict(info.context.user, 'view')

View File

@@ -224,7 +224,7 @@ class CSVFileField(forms.FileField):
return None
csv_str = file.read().decode('utf-8').strip()
reader = csv.reader(csv_str.splitlines())
reader = csv.reader(StringIO(csv_str))
headers, records = parse_csv(reader)
return headers, records
@@ -304,7 +304,7 @@ class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField):
app_label, model = name.split('.')
ct_filter |= Q(app_label=app_label, model=model)
return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True))
return super().prepare_value(value)
return f'{value.app_label}.{value.model}'
#

View File

@@ -0,0 +1,16 @@
import markdown
from markdown.inlinepatterns import SimpleTagPattern
STRIKE_RE = r'(~{2})(.+?)(~{2})'
class StrikethroughExtension(markdown.Extension):
"""
A python-markdown extension which support strikethrough formatting (e.g. "~~text~~").
"""
def extendMarkdown(self, md):
md.inlinePatterns.register(
markdown.inlinepatterns.SimpleTagPattern(STRIKE_RE, 'del'),
'strikethrough',
200
)

View File

@@ -6,6 +6,7 @@ from typing import Dict, Any
import yaml
from django import template
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import date
from django.urls import NoReverseMatch, reverse
from django.utils import timezone
@@ -14,6 +15,7 @@ from django.utils.safestring import mark_safe
from markdown import markdown
from utilities.forms import get_selected_values, TableConfigForm
from utilities.markdown import StrikethroughExtension
from utilities.utils import foreground_color
register = template.Library()
@@ -39,16 +41,21 @@ def render_markdown(value):
"""
Render text as Markdown
"""
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
# Strip HTML tags
value = strip_tags(value)
# Sanitize Markdown links
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
# Sanitize Markdown reference links
pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)'
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
# Render Markdown
html = markdown(value, extensions=['fenced_code', 'tables'])
html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
return mark_safe(html)
@@ -78,6 +85,25 @@ def meta(obj, attr):
return getattr(obj._meta, attr, '')
@register.filter()
def content_type(obj):
"""
Return the ContentType for the given object.
"""
return ContentType.objects.get_for_model(obj)
@register.filter()
def content_type_id(obj):
"""
Return the ContentType ID for the given object.
"""
content_type = ContentType.objects.get_for_model(obj)
if content_type:
return content_type.pk
return None
@register.filter()
def viewname(model, action):
"""

View File

@@ -345,7 +345,7 @@ class APIViewTestCases:
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
id_list = self._get_queryset().values_list('id', flat=True)[:3]
id_list = list(self._get_queryset().values_list('id', flat=True)[:3])
self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk update")
data = [
{'id': id, **self.bulk_update_data} for id in id_list
@@ -416,7 +416,7 @@ class APIViewTestCases:
# Target the three most recently created objects to avoid triggering recursive deletions
# (e.g. with MPTT objects)
id_list = self._get_queryset().order_by('-id').values_list('id', flat=True)[:3]
id_list = list(self._get_queryset().order_by('-id').values_list('id', flat=True)[:3])
self.assertEqual(len(id_list), 3, "Insufficient number of objects to test bulk deletion")
data = [{"id": id} for id in id_list]

View File

@@ -327,13 +327,6 @@ def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict:
return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()}
# Taken from django.utils.functional (<3.0)
def curry(_curried_func, *args, **kwargs):
def _curried(*moreargs, **morekwargs):
return _curried_func(*args, *moreargs, **{**kwargs, **morekwargs})
return _curried
def array_to_string(array):
"""
Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField.

View File

@@ -144,6 +144,12 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConf
queryset=Cluster.objects.all(),
label='Cluster (ID)',
)
cluster = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__name',
queryset=Cluster.objects.all(),
to_field_name='name',
label='Cluster',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='cluster__site__region',

View File

@@ -324,9 +324,8 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
clusters = Cluster.objects.all()[:2]
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: 'cluster' should match on name
# params = {'cluster': [clusters[0].name, clusters[1].name]}
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]

View File

@@ -7,7 +7,7 @@ django-mptt==0.13.4
django-pglocks==1.0.4
django-prometheus==2.1.0
django-redis==5.0.0
django-rq==2.4.1
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==1.5.1
django-timezone-field==4.2.1
@@ -15,13 +15,13 @@ djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
graphene_django==2.15.0
gunicorn==20.1.0
Jinja2==3.0.2
Markdown==3.3.4
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==7.3.6
netaddr==0.8.0
Pillow==8.4.0
psycopg2-binary==2.9.1
psycopg2-binary==2.9.2
PyYAML==6.0
svgwrite==1.4.1
tablib==3.1.0

View File

@@ -11,6 +11,7 @@ exec 1>&2
EXIT=0
RED='\033[0;31m'
YELLOW='\033[0;33m'
NOCOLOR='\033[0m'
if [ -d ./venv/ ]; then
@@ -22,6 +23,11 @@ if [ -d ./venv/ ]; then
fi
fi
if [ ${NOVALIDATE} ]; then
echo "${YELLOW}Skipping validation checks${NOCOLOR}"
exit $EXIT
fi
echo "Validating PEP8 compliance..."
pycodestyle --ignore=W504,E501 --exclude=node_modules netbox/
if [ $? != 0 ]; then