Compare commits

..

65 Commits

Author SHA1 Message Date
Jeremy Stretch
6c53ca8909 Merge pull request #13294 from netbox-community/develop
Release v3.5.7
2023-07-28 10:29:46 -04:00
Jeremy Stretch
4f984c0831 Release v3.5.7 2023-07-28 10:11:16 -04:00
Jeremy Stretch
d9dc6cec3a Changelog for #11803, #13009, #13234, #13285 2023-07-28 10:02:42 -04:00
Jeremy Stretch
90146941b5 Fixes #13285: Cast default u_height value to a decimal for validation 2023-07-28 09:49:09 -04:00
Bruno Blanes
9d0457fe1a Add Brazilian power outlet standard to choices.py (#13012)
* Add Brazilian power outlet standard to choices.py

* Eliminate possible name conflict

* Rename group and add IEC 60906-1 plug type

* Update choices.py

Add Brazilian power port standard
2023-07-28 09:26:46 -04:00
Abhimanyu Saharan
2aa51d0d94 Adds contact assignment bulk import (#13109)
* adds contact assignment bulk import #11307

* Remove unsupported tags field added by NetBoxModelImportForm

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-28 09:23:22 -04:00
Abhimanyu Saharan
7158360dfa moves non-racked devices to tab #11803 2023-07-28 08:59:15 -04:00
Jeremy Stretch
c89193d331 Closes #13080: Differentiate more clearly between old and new version placeholders in upgrade guide 2023-07-28 08:11:28 -04:00
Daniel W. Anner
eeb069048f Adding 100gbase-x-dsfp and 100gbase-x-sfpdd (#13236)
* Adding 100gbase-x-dsfp

* fixing missing comma

* Adding interface `TYPE_100GE_SFP_DD`/`100gbase-x-sfpdd`

* Update netbox/dcim/choices.py

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

---------

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2023-07-27 19:02:08 -04:00
Jeremy Stretch
3e12fbe367 Changelog for #12625, #13051, #13097, #13167, #13233, #13237 2023-07-27 16:42:03 -04:00
kkthxbye-code
4b2922312a Allow the align property on th and td and add CSS rules for overriding text-alignment 2023-07-27 16:38:46 -04:00
Abhimanyu Saharan
0276f29067 adds sensitive_parameters to DataBackend #12625 2023-07-27 16:33:29 -04:00
Roger Miret
1d52627f71 Update ipam.md
100.64.16.9/24 isn't a valid CIDR
2023-07-27 16:07:44 -04:00
Alef Burzmali
bba4fe437c Update the install doc for PostgreSQL 15
Fixes #12768
2023-07-27 16:06:41 -04:00
Abhimanyu Saharan
0ab3f979e0 Adds faster polling for scripts and reports (#13202)
* adds faster polling for scripts and reports #13097

* changes as per review
2023-07-27 15:59:41 -04:00
kkthxbye-code
5a3d46ac8d Remove vlan_group from nullable fields in InterfaceBulkEditForm 2023-07-27 15:58:16 -04:00
Fabian Geisberger
d075e7a66a Fixes #13237 - Allow unauthenticated api access to content-types. 2023-07-27 15:47:34 -04:00
kkthxbye-code
8b8adfbbbb Use class_name instead of name to get script results 2023-07-27 15:32:29 -04:00
Jeremy Stretch
0f0cf683c4 PRVB 2023-07-10 16:55:17 -04:00
Jeremy Stretch
ec0dbe33d3 Merge pull request #13142 from netbox-community/develop
Release v3.5.6
2023-07-10 16:53:46 -04:00
Jeremy Stretch
1c30a44b4e Release v3.5.6 2023-07-10 16:35:53 -04:00
Jeremy Stretch
252cc37f97 Changelog for #13061, #13096, #13105, #13116 2023-07-10 14:39:40 -04:00
Jeremy Stretch
f6fcf776a4 Fixes #13061: Fix display of last result for scripts & reports with a custom name defined 2023-07-10 14:13:45 -04:00
Jeremy Stretch
73348ee435 Fixes #13105: Avoid exception when attempting to allocate next available IP address from prefix marked as utilized 2023-07-10 13:53:31 -04:00
Abhimanyu Saharan
cab7b76220 Fixes form rendering when scheduling_enabled is disabled (#13123)
* fixes form rendering when scheduling_enabled is disabled #13096

* Remove requires_input property from BaseScript; render form consistently

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-10 10:30:51 -04:00
Abhimanyu Saharan
bc7678c716 fixes content type lookups when db is uninitialized #13116 2023-07-07 09:43:33 -04:00
Jeremy Stretch
63c33ff4be PRVB 2023-07-06 16:40:11 -04:00
Jeremy Stretch
da239aea13 Merge pull request #13111 from netbox-community/develop
Release v3.5.5
2023-07-06 16:38:36 -04:00
Jeremy Stretch
53a75a3dd7 Release v3.5.5 2023-07-06 16:20:14 -04:00
Abhimanyu Saharan
74fb707ad3 adds config_template to device serializer #13056 2023-07-06 16:04:21 -04:00
Jeremy Stretch
ecb4a084cc Change log for #11738, #12499, #12579, #12617, #13047, #13065, #13092, #13100 2023-07-06 14:54:37 -04:00
Jeremy Stretch
7419a8e112 Closes #11738: Annotate utilization on VLAN groups (#13108)
* Update serializers.py

* Update vlans.py

* Update vlans.py

* Update vlangroup.html

* Update vlans.py

* Update vlans.py

* Update serializers.py

* adds db annotation to calculate utilization

* optimize queries

* merge fix

* adds round function for utilization to limit decimal

* fixed object view annotation

* consolidated queryset for utilization

* lint fixes

* Renamed manager method to annotate_utilization() for consistency with other managers

---------

Co-authored-by: Abhimanyu Saharan <desk.abhimanyu@gmail.com>
2023-07-06 14:51:28 -04:00
Abhimanyu Saharan
62bdb90f61 Adds copy content button (#12584)
* adds copy content button #12499

* adds newline

* Omit hash mark from target string

* Clean up HTML element IDs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-06 14:19:55 -04:00
Abhimanyu Saharan
8143c6e03b adds object change for contact assignment #13065 2023-07-06 09:45:31 -04:00
Abhimanyu Saharan
ffe4558ec5 fixes search for vdc #13100 2023-07-06 09:41:43 -04:00
Abhimanyu Saharan
16ee42ac38 fixes prechange snapshot #12617 2023-07-06 09:39:09 -04:00
Anthony Brissonnet
860be780ad Fix #12579 create cable and add another error (#13007)
* fix create cable and add another error #12579

* fix return proper parent object field

* improve code and wokflow

---------

Co-authored-by: netopsab <abrisson@cerbere.rp.sig.u-bourgogne.fr>
2023-07-06 09:28:45 -04:00
Jeremy Stretch
5f0922713f Fixes #13047: Add annotate_asn_count() to ASNRange manager 2023-07-06 08:45:02 -04:00
Arthur
4355ee6407 12092 allow setnull for bulk edit power port maximum and allocated draw 2023-07-06 08:37:54 -04:00
Jeremy Stretch
07ae7c8a6e Changelog for #11335, #12760, #12842, #12951, #12955 2023-07-05 11:43:53 -04:00
Jeremy Stretch
63ba9fb38c Fixes #11335: Default manager for ObjectChange should filter by installed apps (#11709)
* Fixes #11335: Default manager for ObjectChange should filter by installed apps

* Employ canonical model discovery mechanism

* Move filtering logic to valid_models() queryset method

* fixed import to avoid content type does not exist

* Cleanup

---------

Co-authored-by: Abhimanyu Saharan <desk.abhimanyu@gmail.com>
2023-07-05 11:39:35 -04:00
Abhimanyu Saharan
3307bd200c Fixes syntax error on reports (#12997)
* fixes syntax error on reports #12842

* remove the extra filter #12842
2023-06-29 16:02:39 -04:00
Daniel Sheppard
f69d99ea67 Fixes #12760 - Adds Vary header to cause cache to be keyed based on URL and the HX-Request header (or lack thereof) 2023-06-29 15:53:57 -04:00
Arthur
3754e00ee0 12809 document not to use underscores in model names 2023-06-29 15:49:47 -04:00
pobradovic08
dd6d9bf6e3 List multiple devices in Terminations (#13030) 2023-06-29 15:48:00 -04:00
Abhimanyu Saharan
183c7deb81 adds contact accessor fields on assignment table #12955 2023-06-29 14:50:30 -04:00
Jeremy Stretch
0a60a3fd2a Add OneMind Services as a sponsor 2023-06-29 14:47:10 -04:00
Jeremy Stretch
b13f9d27d9 Fix Repography charts 2023-06-27 10:19:12 -04:00
Jeremy Stretch
6b01b1df40 Changelog for #12849, #12945, #12961, #12977, #12983, #13011 2023-06-27 08:39:51 -04:00
Harm Geerts
34d32374a8 Fix #12983 bulk edit of M2M fields when nothing is selected
Partially revert 41c92483a0 to restore
bulk edit with m2m fields. The m2m cleaned_data yields a empty queryset
when nothing is selected. By setting the m2m relation unless set null is
checked even when nothing is selected the m2m relation is always
cleared.

This commit only sets the m2m relation when a selection is made.
2023-06-27 08:28:19 -04:00
rmanyari
c99e565426 fix remote auth backend check 2023-06-27 08:09:50 -04:00
pobradovic08
16d5107b71 Add QSFP-DD (100GE) interface type 2023-06-27 08:02:46 -04:00
Alexander Votteler
f1858a7c23 Fixes #13011: Allow comma in custom links 2023-06-27 08:00:33 -04:00
Jeremy Stretch
290ffd408a Fixes #12977: Fix URL parameters for object count dashboard widgets (#12991)
* Fixes #12977: Introduce dict_to_querydict() to ensure proper handling of QueryDicts

* Remove unused import
2023-06-26 14:21:26 -04:00
Jeremy Stretch
74d9fe1ea2 Fixes #12961: Set correct return_url for object contacts tabs 2023-06-26 12:50:54 -04:00
Jeremy Stretch
d131d9b310 Correct changelog for #12953 2023-06-23 15:31:18 -04:00
Jeremy Stretch
32fe9fe8ec Changelog for #12533, #12960, #12963, #12966, #12975, #12989 2023-06-23 15:26:48 -04:00
Jeremy Stretch
882f29192c Fixes #12975: Correct URL for VirtualDeviceContext API serializer 2023-06-23 15:23:40 -04:00
Abhimanyu Saharan
27e850a68d fix ip address assignment #12953 2023-06-23 14:39:16 -04:00
Abhimanyu Saharan
c83b2499f0 fix db maintenance mode exception #12966 2023-06-23 14:36:50 -04:00
Abhimanyu Saharan
79c8219202 fixes device interface serializer #12533 2023-06-23 14:34:08 -04:00
Abhimanyu Saharan
49af70a77d fixes choice field schema for response #12960 2023-06-23 14:21:51 -04:00
Abhimanyu Saharan
7f96c7fee7 fixes most schema warnings #12257 2023-06-23 14:19:40 -04:00
Abhimanyu Saharan
13315f36d4 fixes device type and module type tag import #12949 2023-06-23 14:17:28 -04:00
Jeremy Stretch
70c2b358ad PRVB 2023-06-20 14:27:18 -04:00
78 changed files with 660 additions and 249 deletions

View File

@@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.5.4
placeholder: v3.5.7
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.5.4
placeholder: v3.5.7
validations:
required: true
- type: dropdown

View File

@@ -52,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
## Project Stats
<div align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
@@ -66,10 +66,12 @@ 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)
<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;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div>

View File

@@ -38,7 +38,7 @@ An example hierarchy might look like this:
* 100.64.16.1/24 (address)
* 100.64.16.2/24 (address)
* 100.64.16.3/24 (address)
* 100.64.16.9/24 (prefix)
* 100.64.19.0/24 (prefix)
* 100.64.32.0/20 (prefix)
* 100.64.32.1/24 (address)
* 100.64.32.10-99/24 (range)

View File

@@ -55,6 +55,9 @@ Within the shell, enter the following commands to create the database and user (
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
ALTER DATABASE netbox OWNER TO netbox;
-- the next two commands are needed on PostgreSQL 15 and later
\connect netbox;
GRANT CREATE ON SCHEMA public TO netbox;
```
!!! danger "Use a strong password"

View File

@@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
Download and extract the latest version:
```no-highlight
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
# Set $NEWVER to the NetBox version being installed
NEWVER=3.5.0
wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
```
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
```no-highlight
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
# Set $OLDVER to the NetBox version currently installed
NEWVER=3.4.9
sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/
sudo cp -pr /opt/netbox-$OLDVER/netbox/media/ /opt/netbox/netbox/
```
Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
```no-highlight
sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/
sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/
sudo cp -r /opt/netbox-$OLDVER/netbox/scripts /opt/netbox/netbox/
sudo cp -r /opt/netbox-$OLDVER/netbox/reports /opt/netbox/netbox/
```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/
sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
```
### Option B: Clone the Git Repository

View File

@@ -19,6 +19,9 @@ class MyModel(models.Model):
Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`.
!!! note
Model names should adhere to [PEP8](https://www.python.org/dev/peps/pep-0008/#class-names) standards and be CapWords (no underscores). Using underscores in model names will result in problems with permissions.
## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including:

View File

@@ -1,5 +1,72 @@
# NetBox v3.5
## v3.5.7 (2023-07-28)
### Enhancements
* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view
* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source
* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types
* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
### Bug Fixes
* [#13051](https://github.com/netbox-community/netbox/issues/13051) - Fix Markdown support for table cell alignment
* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API
* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces
* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false
* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value
---
## v3.5.6 (2023-07-10)
### Bug Fixes
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
---
## v3.5.5 (2023-07-06)
### Enhancements
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
### Bug Fixes
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
---
## v3.5.4 (2023-06-20)
### Enhancements

View File

@@ -1,5 +1,6 @@
import re
import typing
from collections import OrderedDict
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
from drf_spectacular.openapi import AutoSchema
@@ -28,14 +29,19 @@ class ChoiceFieldFix(OpenApiSerializerFieldExtension):
target_class = 'netbox.api.fields.ChoiceField'
def map_serializer_field(self, auto_schema, direction):
build_cf = build_choice_field(self.target)
if direction == 'request':
return build_choice_field(self.target)
return build_cf
elif direction == "response":
value = build_cf
label = {**build_basic_type(OpenApiTypes.STR), "enum": list(OrderedDict.fromkeys(self.target.choices.values()))}
return build_object_type(
properties={
"value": build_basic_type(OpenApiTypes.STR),
"label": build_basic_type(OpenApiTypes.STR),
"value": value,
"label": label
}
)

View File

@@ -41,6 +41,7 @@ def register_backend(name):
class DataBackend:
parameters = {}
sensitive_parameters = []
def __init__(self, url, **kwargs):
self.url = url
@@ -86,6 +87,7 @@ class GitBackend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'})
)
}
sensitive_parameters = ['password']
@contextmanager
def fetch(self):
@@ -135,6 +137,7 @@ class S3Backend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'})
),
}
sensitive_parameters = ['aws_secret_access_key']
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'

View File

@@ -698,7 +698,8 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'config_template',
'created', 'last_updated',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -707,7 +708,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = NestedDeviceSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
@@ -880,12 +881,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -907,9 +908,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
mac_address = serializers.CharField(
required=False,
default=None,
allow_blank=True,
allow_null=True
)
wwn = serializers.CharField(required=False, default=None)
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
class Meta:
model = Interface

View File

@@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p'
@@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'),
@@ -553,6 +562,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r'
@@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'),
@@ -809,7 +827,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_DSFP = '100gbase-x-dsfp'
TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
@@ -958,7 +979,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_DSFP, 'DSFP (100GE)'),
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),

View File

@@ -1077,10 +1077,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(identifier=value.strip())
).distinct()
qs_filter = Q(name__icontains=value)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
pass
return queryset.filter(qs_filter).distinct()
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)

View File

@@ -1106,7 +1106,7 @@ class PowerPortBulkEditForm(
(None, ('module', 'type', 'label', 'description', 'mark_connected')),
('Power', ('maximum_draw', 'allocated_draw')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'maximum_draw', 'allocated_draw')
class PowerOutletBulkEditForm(
@@ -1259,8 +1259,8 @@ class InterfaceBulkEditForm(
)
nullable_fields = (
'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', 'wireless_lans'
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
'tagged_vlans', 'vrf', 'wireless_lans'
)
def __init__(self, *args, **kwargs):

View File

@@ -306,7 +306,7 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
model = DeviceType
fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
]
@@ -327,7 +327,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
class DeviceRoleImportForm(NetBoxModelImportForm):

View File

@@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin):
super().clean()
# U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5):
if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units."
})

View File

@@ -681,13 +681,6 @@ class RackView(generic.ObjectView):
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
)
# Get 0U devices located within the rack
nonracked_devices = Device.objects.filter(
rack=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location:
@@ -704,7 +697,6 @@ class RackView(generic.ObjectView):
return {
'related_models': related_models,
'nonracked_devices': nonracked_devices,
'next_rack': next_rack,
'prev_rack': prev_rack,
'svg_extra': svg_extra,
@@ -731,6 +723,26 @@ class RackRackReservationsView(generic.ObjectChildrenView):
return parent.reservations.restrict(request.user, 'view')
@register_model_view(Rack, 'nonracked_devices', 'nonracked-devices')
class RackNonRackedView(generic.ObjectChildrenView):
queryset = Rack.objects.all()
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab(
label=_('Non-Racked Devices'),
badge=lambda obj: obj.devices.filter(rack=obj, position__isnull=True, parent_bay__isnull=True).count(),
weight=500,
permission='dcim.view_device',
)
def get_children(self, request, parent):
return parent.devices.restrict(request.user, 'view').filter(
rack=parent, position__isnull=True, parent_bay__isnull=True
)
@register_model_view(Rack, 'edit')
class RackEditView(generic.ObjectEditView):
queryset = Rack.objects.all()
@@ -3131,6 +3143,19 @@ class CableEditView(generic.ObjectEditView):
return obj
def get_extra_addanother_params(self, request):
params = {
'a_terminations_type': request.GET.get('a_terminations_type'),
'b_terminations_type': request.GET.get('b_terminations_type')
}
for key in request.POST:
if 'device' in key or 'power_panel' in key or 'circuit' in key:
params.update({key: request.POST.get(key)})
return params
@register_model_view(Cable, 'delete')
class CableDeleteView(generic.ObjectDeleteView):

View File

@@ -6,7 +6,6 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
@@ -303,7 +302,7 @@ class ScriptViewSet(ViewSet):
# Attach Job objects to each script (if any)
for script in script_list:
script.result = results.get(script.name, None)
script.result = results.get(script.class_name, None)
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
@@ -314,7 +313,7 @@ class ScriptViewSet(ViewSet):
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
script.result = Job.objects.filter(
object_type=object_type,
name=script.name,
name=script.class_name,
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
).first()
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
@@ -368,7 +367,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
Retrieve a list of recent changes.
"""
metadata_class = ContentTypeMetadata
queryset = ObjectChange.objects.prefetch_related('user')
queryset = ObjectChange.objects.valid_models().prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filtersets.ObjectChangeFilterSet
@@ -381,7 +380,7 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ContentTypes. Limit results to ContentTypes pertinent to NetBox objects.
"""
permission_classes = (IsAuthenticated,)
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet

View File

@@ -10,7 +10,6 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.db.models import Q
from django.http import QueryDict
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
@@ -19,7 +18,7 @@ from extras.utils import FeatureQuery
from utilities.forms import BootstrapMixin
from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name, get_viewname
from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname
from .utils import register_widget
__all__ = (
@@ -170,8 +169,7 @@ class ObjectCountsWidget(DashboardWidget):
qs = model.objects.restrict(request.user, 'view')
# Apply any specified filters
if filters := self.config.get('filters'):
params = QueryDict(mutable=True)
params.update(filters)
params = dict_to_querydict(filters)
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
qs = filterset(params, qs).qs
url = f'{url}?{params.urlencode()}'

View File

@@ -56,10 +56,3 @@ class ScriptForm(BootstrapMixin, forms.Form):
self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the built-in fields).
"""
return bool(len(self.fields) > 3)

View File

@@ -5,7 +5,7 @@ from django.db import models
from django.urls import reverse
from extras.choices import *
from utilities.querysets import RestrictedQuerySet
from ..querysets import ObjectChangeQuerySet
__all__ = (
'ObjectChange',
@@ -82,7 +82,7 @@ class ObjectChange(models.Model):
null=True
)
objects = RestrictedQuerySet.as_manager()
objects = ObjectChangeQuerySet.as_manager()
class Meta:
ordering = ['-time']

View File

@@ -9,7 +9,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse, QueryDict
from django.http import HttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.formats import date_format
@@ -26,7 +26,7 @@ from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin,
)
from utilities.querysets import RestrictedQuerySet
from utilities.utils import clean_html, render_jinja2
from utilities.utils import clean_html, dict_to_querydict, render_jinja2
__all__ = (
'ConfigRevision',
@@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#,')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)
@@ -462,8 +462,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
@property
def url_params(self):
qd = QueryDict(mutable=True)
qd.update(self.parameters)
qd = dict_to_querydict(self.parameters)
return qd.urlencode()

View File

@@ -1,7 +1,7 @@
import inspect
import logging
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
@@ -12,6 +12,8 @@ from netbox.models.features import JobsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet
from .mixins import PythonModuleMixin
logger = logging.getLogger('netbox.reports')
__all__ = (
'Report',
'ReportModule',
@@ -56,7 +58,8 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
try:
module = self.get_module()
except ImportError:
except (ImportError, SyntaxError) as e:
logger.error(f"Unable to load report module {self.name}, exception: {e}")
return {}
reports = {}
ordered = getattr(module, 'report_order', [])

View File

@@ -1,5 +1,8 @@
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.aggregates import JSONBAgg
from django.db.models import OuterRef, Subquery, Q
from django.db.utils import ProgrammingError
from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg
@@ -151,3 +154,20 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
)
return base_query
class ObjectChangeQuerySet(RestrictedQuerySet):
def valid_models(self):
# Exclude any change records which refer to an instance of a model that's no longer installed. This
# can happen when a plugin is removed but its data remains in the database, for example.
try:
content_types = ContentType.objects.get_for_models(*apps.get_models()).values()
except ProgrammingError:
# Handle the case where the database schema has not yet been initialized
content_types = ContentType.objects.none()
content_type_ids = set(
ct.pk for ct in content_types
)
return self.filter(changed_object_type_id__in=content_type_ids)

View File

@@ -366,7 +366,7 @@ class BaseScript:
if self.fieldsets:
fieldsets.extend(self.fieldsets)
else:
fields = (name for name, _ in self._get_vars().items())
fields = list(name for name, _ in self._get_vars().items())
fieldsets.append(('Script Data', fields))
# Append the default fieldset if defined in the Meta class
@@ -390,6 +390,11 @@ class BaseScript:
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = self.commit_default
# Hide fields if scheduling has been disabled
if not self.scheduling_enabled:
form.fields['_schedule_at'].widget = forms.HiddenInput()
form.fields['_interval'].widget = forms.HiddenInput()
return form
# Logging

View File

@@ -8,7 +8,6 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import *
from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
@@ -579,6 +578,7 @@ class ReportTest(APITestCase):
super().setUp()
# Monkey-patch the API viewset's _get_report() method to return our test Report above
from extras.api.views import ReportViewSet
ReportViewSet._get_report = self.get_test_report
def test_get_report(self):
@@ -621,6 +621,7 @@ class ScriptTest(APITestCase):
super().setUp()
# Monkey-patch the API viewset's _get_script() method to return our test Script above
from extras.api.views import ScriptViewSet
ScriptViewSet._get_script = self.get_test_script
def test_get_script(self):

View File

@@ -511,7 +511,7 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
#
class ObjectChangeListView(generic.ObjectListView):
queryset = ObjectChange.objects.all()
queryset = ObjectChange.objects.valid_models()
filterset = filtersets.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable
@@ -521,10 +521,10 @@ class ObjectChangeListView(generic.ObjectListView):
@register_model_view(ObjectChange)
class ObjectChangeView(generic.ObjectView):
queryset = ObjectChange.objects.all()
queryset = ObjectChange.objects.valid_models()
def get_extra_context(self, request, instance):
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
related_changes = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
request_id=instance.request_id
).exclude(
pk=instance.pk
@@ -534,7 +534,7 @@ class ObjectChangeView(generic.ObjectView):
orderable=False
)
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
objectchanges = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
changed_object_type=instance.changed_object_type,
changed_object_id=instance.changed_object_id,
)

View File

@@ -218,12 +218,13 @@ class VLANGroupSerializer(NetBoxModelSerializer):
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
utilization = serializers.CharField(read_only=True)
class Meta:
model = VLANGroup
fields = [
'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
validators = []

View File

@@ -1,5 +1,7 @@
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import transaction
from django.db.models import F
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_spectacular.utils import extend_schema
@@ -145,9 +147,7 @@ class FHRPGroupAssignmentViewSet(NetBoxModelViewSet):
class VLANGroupViewSet(NetBoxModelViewSet):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
).prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
serializer_class = serializers.VLANGroupSerializer
filterset_class = filtersets.VLANGroupFilterSet

View File

@@ -4,6 +4,8 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes
from netaddr.core import AddrFormatError
from dcim.models import Device, Interface, Region, Site, SiteGroup
@@ -414,6 +416,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
except (AddrFormatError, ValueError):
return queryset.none()
@extend_schema_field(OpenApiTypes.STR)
def filter_present_in_vrf(self, queryset, name, vrf):
if vrf is None:
return queryset.none
@@ -659,6 +662,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset
return queryset.filter(address__net_mask_length=value)
@extend_schema_field(OpenApiTypes.STR)
def filter_present_in_vrf(self, queryset, name, vrf):
if vrf is None:
return queryset.none
@@ -727,6 +731,7 @@ class FHRPGroupFilterSet(NetBoxModelFilterSet):
Q(name__icontains=value)
)
@extend_schema_field(OpenApiTypes.STR)
def filter_related_ip(self, queryset, name, value):
"""
Filter by VRF & prefix of assigned IP addresses.
@@ -941,9 +946,11 @@ class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
pass
return queryset.filter(qs_filter)
@extend_schema_field(OpenApiTypes.STR)
def get_for_device(self, queryset, name, value):
return queryset.get_for_device(value)
@extend_schema_field(OpenApiTypes.STR)
def get_for_virtualmachine(self, queryset, name, value):
return queryset.get_for_virtualmachine(value)

View File

@@ -345,7 +345,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
})
elif selected_objects:
assigned_object = self.cleaned_data[selected_objects[0]]
if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
if self.instance.pk and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
raise ValidationError(
"Cannot reassign IP address while it is designated as the primary IP for the parent object"
)
@@ -379,6 +379,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
interface = self.instance.assigned_object
if type(interface) in (Interface, VMInterface):
parent = interface.parent_object
parent.snapshot()
if self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress

View File

@@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from ipam.fields import ASNField
from ipam.querysets import ASNRangeQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
__all__ = (
@@ -37,6 +38,8 @@ class ASNRange(OrganizationalModel):
null=True
)
objects = ASNRangeQuerySet.as_manager()
class Meta:
ordering = ('name',)
verbose_name = 'ASN range'

View File

@@ -406,7 +406,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
Return all available IPs within this prefix as an IPSet.
"""
if self.mark_utilized:
return list()
return netaddr.IPSet()
prefix = netaddr.IPSet(self.prefix)
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])

View File

@@ -9,7 +9,7 @@ from django.utils.translation import gettext as _
from dcim.models import Interface
from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
from virtualization.models import VMInterface
@@ -63,6 +63,8 @@ class VLANGroup(OrganizationalModel):
help_text=_('Highest permissible ID of a child VLAN')
)
objects = VLANGroupQuerySet.as_manager()
class Meta:
ordering = ('name', 'pk') # Name may be non-unique
constraints = (

View File

@@ -1,8 +1,34 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.db.models import Count, F, OuterRef, Q, Subquery, Value
from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from utilities.querysets import RestrictedQuerySet
from utilities.utils import count_related
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
'VLANQuerySet',
)
class ASNRangeQuerySet(RestrictedQuerySet):
def annotate_asn_counts(self):
"""
Annotate the number of ASNs which appear within each range.
"""
from .models import ASN
# Because ASN does not have a foreign key to ASNRange, we create a fake column "_" with a consistent value
# that we can use to count ASNs and return a single value per ASNRange.
asns = ASN.objects.filter(
asn__gte=OuterRef('start'),
asn__lte=OuterRef('end')
).order_by().annotate(_=Value(1)).values('_').annotate(c=Count('*')).values('c')
return self.annotate(asn_count=Subquery(asns))
class PrefixQuerySet(RestrictedQuerySet):
@@ -30,6 +56,17 @@ class PrefixQuerySet(RestrictedQuerySet):
)
class VLANGroupQuerySet(RestrictedQuerySet):
def annotate_utilization(self):
from .models import VLAN
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
)
class VLANQuerySet(RestrictedQuerySet):
def get_for_device(self, device):

View File

@@ -21,10 +21,8 @@ class ASNRangeTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:asnrange_list'
)
asn_count = columns.LinkedCountColumn(
viewname='ipam:asn_list',
url_params={'asn_id': 'pk'},
verbose_name=_('ASN Count')
asn_count = tables.Column(
verbose_name=_('ASNs')
)
class Meta(NetBoxTable.Meta):
@@ -59,7 +57,8 @@ class ASNTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Provider Count')
)
sites = columns.ManyToManyColumn(
linkify_item=True
linkify_item=True,
verbose_name=_('Sites')
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(

View File

@@ -19,14 +19,22 @@ __all__ = (
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
AGGREGATE_COPY_BUTTON = """
{% copy_content record.pk prefix="aggregate_" %}
"""
PREFIX_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
<a href="{{ record.get_absolute_url }}" id="prefix_{{ record.pk }}">{{ record.prefix }}</a>
{% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %}
"""
PREFIX_COPY_BUTTON = """
{% copy_content record.pk prefix="prefix_" %}
"""
PREFIX_LINK_WITH_DEPTH = """
{% load helpers %}
{% if record.depth %}
@@ -40,7 +48,7 @@ PREFIX_LINK_WITH_DEPTH = """
IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
<a href="{{ record.get_absolute_url }}" id="ipaddress_{{ record.pk }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.tenant %}&tenant={{ object.tenant.pk }}{% endif %}" class="btn btn-sm btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
@@ -48,6 +56,10 @@ IPADDRESS_LINK = """
{% endif %}
"""
IPADDRESS_COPY_BUTTON = """
{% copy_content record.pk prefix="ipaddress_" %}
"""
IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?{% if request.GET.interface %}interface={{ request.GET.interface }}{% elif request.GET.vminterface %}vminterface={{ request.GET.vminterface }}{% endif %}&return_url={{ request.GET.return_url }}">{{ record }}</a>
"""
@@ -99,7 +111,11 @@ class RIRTable(NetBoxTable):
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
prefix = tables.Column(
linkify=True,
verbose_name='Aggregate'
verbose_name='Aggregate',
attrs={
# Allow the aggregate to be copied to the clipboard
'a': {'id': lambda record: f"aggregate_{record.pk}"}
}
)
date_added = tables.DateColumn(
format="Y-m-d",
@@ -116,6 +132,9 @@ class AggregateTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:aggregate_list'
)
actions = columns.ActionsColumn(
extra_buttons=AGGREGATE_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = Aggregate
@@ -242,6 +261,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:prefix_list'
)
actions = columns.ActionsColumn(
extra_buttons=PREFIX_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = Prefix
@@ -348,6 +370,9 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
tags = columns.TagColumn(
url_name='ipam:ipaddress_list'
)
actions = columns.ActionsColumn(
extra_buttons=IPADDRESS_COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = IPAddress

View File

@@ -70,6 +70,10 @@ class VLANGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='VLANs'
)
utilization = columns.UtilizationColumn(
orderable=False,
verbose_name='Utilization'
)
tags = columns.TagColumn(
url_name='ipam:vlangroup_list'
)
@@ -81,9 +85,9 @@ class VLANGroupTable(NetBoxTable):
model = VLANGroup
fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions',
'tags', 'created', 'last_updated', 'actions', 'utilization',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description')
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')
#

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Prefetch
from django.db.models import F, Prefetch
from django.db.models.expressions import RawSQL
from django.db.models.functions import Round
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
@@ -198,7 +199,7 @@ class RIRBulkDeleteView(generic.BulkDeleteView):
#
class ASNRangeListView(generic.ObjectListView):
queryset = ASNRange.objects.all()
queryset = ASNRange.objects.annotate_asn_counts()
filterset = filtersets.ASNRangeFilterSet
filterset_form = forms.ASNRangeFilterForm
table = tables.ASNRangeTable
@@ -247,18 +248,14 @@ class ASNRangeBulkImportView(generic.BulkImportView):
class ASNRangeBulkEditView(generic.BulkEditView):
queryset = ASNRange.objects.annotate(
site_count=count_related(Site, 'asns')
)
queryset = ASNRange.objects.annotate_asn_counts()
filterset = filtersets.ASNRangeFilterSet
table = tables.ASNRangeTable
form = forms.ASNRangeBulkEditForm
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
queryset = ASNRange.objects.annotate(
site_count=count_related(Site, 'asns')
)
queryset = ASNRange.objects.annotate_asn_counts()
filterset = filtersets.ASNRangeFilterSet
table = tables.ASNRangeTable
@@ -886,9 +883,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
#
class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
@@ -896,7 +891,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(generic.ObjectView):
queryset = VLANGroup.objects.all()
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
def get_extra_context(self, request, instance):
related_models = (
@@ -938,18 +933,14 @@ class VLANGroupBulkImportView(generic.BulkImportView):
class VLANGroupBulkEditView(generic.BulkEditView):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
filterset = filtersets.VLANGroupFilterSet
table = tables.VLANGroupTable
form = forms.VLANGroupBulkEditForm
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.annotate(
vlan_count=count_related(VLAN, 'group')
)
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
filterset = filtersets.VLANGroupFilterSet
table = tables.VLANGroupTable

View File

@@ -60,7 +60,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
user = token.user
# When LDAP authentication is active try to load user data from LDAP directory
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
if 'netbox.authentication.LDAPBackend' in settings.REMOTE_AUTH_BACKEND:
from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend()

View File

@@ -49,6 +49,9 @@ class CoreMiddleware:
# Attach the unique request ID as an HTTP header.
response['X-Request-ID'] = request.id
# Enable the Vary header to help with caching of HTMX responses
response['Vary'] = 'HX-Request'
# If this is an API request, attach an HTTP header annotating the API version (e.g. '3.5').
if is_api_request(request):
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
@@ -203,7 +206,7 @@ class MaintenanceModeMiddleware:
"""
Prevent any write-related database operations if an exception is raised.
"""
if isinstance(exception, InternalError):
if get_config().MAINTENANCE_MODE and isinstance(exception, InternalError):
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
'operations. Please try again later.'

View File

@@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'contact', _('Contacts')),
get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
get_model_item('tenancy', 'contactrole', _('Contact Roles')),
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]),
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']),
),
),
),

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.5.4'
VERSION = '3.5.7'
# Hostname
HOSTNAME = platform.node()

View File

@@ -551,7 +551,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
for name, m2m_field in m2m_fields.items():
if name in form.nullable_fields and name in nullified_fields:
getattr(obj, name).clear()
else:
elif form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name])
# Add/remove 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@ import Clipboard from 'clipboard';
import { getElements } from './util';
export function initClipboard(): void {
for (const element of getElements('a.copy-token', 'button.copy-secret')) {
for (const element of getElements('a.copy-content')) {
new Clipboard(element);
}
}

View File

@@ -1002,6 +1002,18 @@ div.card-overlay {
padding: 8px;
}
th[align="left"] {
text-align: left;
}
th[align="center"] {
text-align: center;
}
th[align="right"] {
text-align: right;
}
/* Markdown widget */
.markdown-widget {
.nav-link {

View File

@@ -39,9 +39,7 @@
<th scope="row">Path</th>
<td>
<span class="font-monospace" id="datafile_path">{{ object.path }}</span>
<a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_path" title="Copy to clipboard">
<i class="mdi mdi-content-copy"></i>
</a>
{% copy_content "datafile_path" %}
</td>
</tr>
<tr>
@@ -56,9 +54,7 @@
<th scope="row">SHA256 Hash</th>
<td>
<span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
<a class="btn btn-sm btn-primary copy-token" data-clipboard-target="#datafile_hash" title="Copy to clipboard">
<i class="mdi mdi-content-copy"></i>
</a>
{% copy_content "datafile_hash" %}
</td>
</tr>
</table>

View File

@@ -88,7 +88,11 @@
{% for name, field in object.get_backend.parameters.items %}
<tr>
<th scope="row">{{ field.label }}</th>
<td>{{ object.parameters|get_key:name|placeholder }}</td>
{% if name in object.get_backend.sensitive_parameters and not perms.core.change_datasource %}
<td>********</td>
{% else %}
<td>{{ object.parameters|get_key:name|placeholder }}</td>
{% endif %}
</tr>
{% empty %}
<tr>

View File

@@ -194,12 +194,13 @@
<th scope="row">Primary IPv4</th>
<td>
{% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}">{{ object.primary_ip4.address.ip }}</a>
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -209,12 +210,13 @@
<th scope="row">Primary IPv6</th>
<td>
{% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}">{{ object.primary_ip6.address.ip }}</a>
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -15,15 +15,14 @@
<td>Rack</td>
<td>{{ terminations.0.device.rack|linkify|placeholder }}</td>
</tr>
<tr>
<td>Device</td>
<td>{{ terminations.0.device|linkify }}</td>
</tr>
<tr>
<td>{{ terminations.0|meta:"verbose_name"|capfirst }}</td>
<td>
{% for term in terminations %}
{{ term|linkify }}{% if not forloop.last %},{% endif %}
{{term.device|linkify}}
<i class="mdi mdi-chevron-right" aria-hidden="true"></i>
{{ term|linkify }}
{% if not forloop.last %}<br/>{% endif %}
{% endfor %}
</td>
</tr>

View File

@@ -190,7 +190,6 @@
</div>
</div>
{% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -0,0 +1,51 @@
{% extends 'dcim/rack/base.html' %}
{% load helpers %}
{% block extra_controls %}
{% if perms.dcim.add_device %}
<div class="bulk-button-group">
<a href="{% url 'dcim:device_add' %}?rack={{ object.pk }}&site={{ object.site.pk }}&return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add non-racked device
</a>
</div>
{% endif %}
{% endblock %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<button type="submit" name="_edit"
formaction="{% url 'dcim:device_bulk_edit' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
</button>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit"
formaction="{% url 'dcim:device_bulk_delete' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
</button>
{% endif %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -31,13 +31,23 @@
<tr>
<th scope="row">Primary IPv4</th>
<td>
{{ object.primary_ip4|linkify|placeholder }}
{% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4 }}</a>
{% copy_content "primary_ip4" %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Primary IPv6</th>
<td>
{{ object.primary_ip6|linkify|placeholder }}
{% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6 }}</a>
{% copy_content "primary_ip6" %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
</tr>
<tr>

View File

@@ -38,71 +38,77 @@
</h5>
<div class="card-body">
{% include 'inc/sync_warning.html' with object=module %}
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th>Description</th>
<th>Last Run</th>
<th>Status</th>
<th width="120"></th>
</tr>
</thead>
<tbody>
{% with jobs=module.get_latest_jobs %}
{% for report_name, report in module.reports.items %}
{% with last_job=jobs|get_key:report.name %}
<tr>
<td>
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</td>
<td>{{ report.description|markdown|placeholder }}</td>
{% if last_job %}
<td>
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
<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 last_job %}
<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 last_job.data.items %}
{% if module.reports %}
<table class="table table-hover table-headings reports">
<thead>
<tr>
<th width="250">Name</th>
<th>Description</th>
<th>Last Run</th>
<th>Status</th>
<th width="120"></th>
</tr>
</thead>
<tbody>
{% with jobs=module.get_latest_jobs %}
{% for report_name, report in module.reports.items %}
{% with last_job=jobs|get_key:report.class_name %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
<td>
<a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
</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>{{ report.description|markdown|placeholder }}</td>
{% if last_job %}
<td>
<a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %}
<td class="text-muted">Never</td>
<td>{{ ''|placeholder }}</td>
{% endif %}
<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 last_job %}
<i class="mdi mdi-replay"></i> Run Again
{% else %}
<i class="mdi mdi-play"></i> Run Report
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>
{% for method, stats in last_job.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 %}
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>
{% else %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
</div>
{% endif %}
</div>
</div>
{% empty %}

View File

@@ -4,7 +4,7 @@
{% block content-wrapper %}
<div class="row p-3">
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
{% include 'extras/htmx/report_result.html' %}
</div>
</div>

View File

@@ -15,9 +15,9 @@
<form action="" method="post" enctype="multipart/form-data" class="form form-object-edit">
{% csrf_token %}
<div class="field-group my-4">
{% if form.requires_input %}
{# Render grouped fields according to declared fieldsets #}
{% for group, fields in script.get_fieldsets %}
{# Render grouped fields according to declared fieldsets #}
{% for group, fields in script.get_fieldsets %}
{% if fields %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5>
@@ -28,14 +28,8 @@
{% endwith %}
{% endfor %}
</div>
{% endfor %}
{% 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 %}
{% endif %}
{% endfor %}
</div>
<div class="float-end">
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-danger">Cancel</a>

View File

@@ -61,7 +61,7 @@
<td>
{{ script_class.Meta.description|markdown|placeholder }}
</td>
{% with last_result=jobs|get_key:script_class.name %}
{% with last_result=jobs|get_key:script_class.class_name %}
{% if last_result %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>

View File

@@ -47,7 +47,7 @@
<div class="tab-content mb-3">
<div role="tabpanel" class="tab-pane active" id="log">
<div class="row">
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="every 5s"{% endif %}>
<div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:script_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
{% include 'extras/htmx/script_result.html' %}
</div>
</div>

View File

@@ -42,6 +42,10 @@
<th scope="row">Permitted VIDs</th>
<td>{{ object.min_vid }} - {{ object.max_vid }}</td>
</tr>
<tr>
<th scope="row">Utilization</th>
<td>{% utilization_graph object.utilization %}</td>
</tr>
</table>
</div>
</div>

View File

@@ -2,10 +2,12 @@
{% load helpers %}
{% block extra_controls %}
{% if perms.tenancy.add_contactassignment %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm">
{% if perms.tenancy.add_contactassignment %}
{% with viewname=object|viewname:"contacts" %}
<a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}&return_url={% url viewname pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
</a>
{% endwith %}
{% endif %}
{% endblock %}

View File

@@ -8,7 +8,7 @@
<div class="col col-md-12">
{% if not settings.ALLOW_TOKEN_RETRIEVAL %}
<div class="alert alert-danger" role="alert">
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
<i class="mdi mdi-alert"></i> Tokens cannot be retrieved at a later time. You must <a href="#" class="copy-content" data-clipboard-target="#token_id" title="Copy to clipboard">copy the token value</a> below and store it securely.
</div>
{% endif %}
<div class="card">
@@ -19,9 +19,7 @@
<th scope="row">Key</th>
<td>
<div class="float-end">
<a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_id" title="Copy to clipboard">
<i class="mdi mdi-content-copy"></i>
</a>
{% copy_content "token_id" %}
</div>
<div id="token_id">{{ key }}</div>
</td>

View File

@@ -46,12 +46,13 @@
<th scope="row">Primary IPv4</th>
<td>
{% if object.primary_ip4 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip4" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
@@ -61,12 +62,13 @@
<th scope="row">Primary IPv6</th>
<td>
{% if object.primary_ip6 %}
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
<a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip6" %}
{% else %}
{{ ''|placeholder }}
{% endif %}

View File

@@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelImportForm
from tenancy.models import *
from utilities.forms.fields import CSVModelChoiceField, SlugField
from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField
__all__ = (
'ContactAssignmentImportForm',
'ContactImportForm',
'ContactGroupImportForm',
'ContactRoleImportForm',
@@ -81,3 +83,27 @@ class ContactImportForm(NetBoxModelImportForm):
class Meta:
model = Contact
fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags')
class ContactAssignmentImportForm(NetBoxModelImportForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
help_text=_("One or more assigned object types")
)
contact = CSVModelChoiceField(
queryset=Contact.objects.all(),
to_field_name='name',
help_text=_('Assigned contact')
)
role = CSVModelChoiceField(
queryset=ContactRole.objects.all(),
to_field_name='name',
help_text=_('Assigned role')
)
# Remove the tags field added by NetBoxModelImportForm (unsupported by ContactAssignment)
tags = None
class Meta:
model = ContactAssignment
fields = ('content_type', 'object_id', 'contact', 'priority', 'role')

View File

@@ -136,3 +136,8 @@ class ContactAssignment(ChangeLoggedModel):
def get_absolute_url(self):
return reverse('tenancy:contact', args=[self.contact.pk])
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
objectchange.related_object = self.object
return objectchange

View File

@@ -1,4 +1,5 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from netbox.tables import NetBoxTable, columns
from tenancy.models import *
@@ -90,11 +91,40 @@ class ContactAssignmentTable(NetBoxTable):
role = tables.Column(
linkify=True
)
contact_title = tables.Column(
accessor=Accessor('contact__title'),
verbose_name='Contact Title'
)
contact_phone = tables.Column(
accessor=Accessor('contact__phone'),
verbose_name='Contact Phone'
)
contact_email = tables.Column(
accessor=Accessor('contact__email'),
verbose_name='Contact Email'
)
contact_address = tables.Column(
accessor=Accessor('contact__address'),
verbose_name='Contact Address'
)
contact_link = tables.Column(
accessor=Accessor('contact__link'),
verbose_name='Contact Link'
)
contact_description = tables.Column(
accessor=Accessor('contact__description'),
verbose_name='Contact Description'
)
actions = columns.ActionsColumn(
actions=('edit', 'delete')
)
class Meta(NetBoxTable.Meta):
model = ContactAssignment
fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions')
default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority')
fields = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
'contact_email', 'contact_address', 'contact_link', 'contact_description', 'actions'
)
default_columns = (
'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
)

View File

@@ -49,6 +49,7 @@ urlpatterns = [
# Contact assignments
path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'),
path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
path('contact-assignments/import/', views.ContactAssignmentBulkImportView.as_view(), name='contactassignment_import'),
path('contact-assignments/edit/', views.ContactAssignmentBulkEditView.as_view(), name='contactassignment_bulk_edit'),
path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'),
path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))),

View File

@@ -420,6 +420,11 @@ class ContactAssignmentBulkEditView(generic.BulkEditView):
form = forms.ContactAssignmentBulkEditForm
class ContactAssignmentBulkImportView(generic.BulkImportView):
queryset = ContactAssignment.objects.all()
model_form = forms.ContactAssignmentImportForm
class ContactAssignmentBulkDeleteView(generic.BulkDeleteView):
queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet

View File

@@ -12,9 +12,7 @@ ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
<a class="btn btn-sm btn-success copy-token" data-clipboard-target="#token_{{ record.pk }}" title="Copy to clipboard">
<i class="mdi mdi-content-copy"></i>
</a>
{% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""

View File

@@ -159,7 +159,9 @@ class ProfileView(LoginRequiredMixin, View):
def get(self, request):
# Compile changelog table
changelog = ObjectChange.objects.restrict(request.user, 'view').filter(user=request.user).prefetch_related(
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)

View File

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

View File

@@ -1,9 +1,12 @@
from django import template
from django.http import QueryDict
from utilities.utils import dict_to_querydict
__all__ = (
'badge',
'checkmark',
'copy_content',
'customfield_value',
'tag',
)
@@ -77,6 +80,17 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
}
@register.inclusion_tag('builtins/copy_content.html')
def copy_content(target, prefix=None, color='primary'):
"""
Display a copy button to copy the content of a field.
"""
return {
'target': f'#{prefix or ""}{target}',
'color': f'btn-{color}'
}
@register.inclusion_tag('builtins/htmx_table.html', takes_context=True)
def htmx_table(context, viewname, return_url=None, **kwargs):
"""
@@ -87,8 +101,7 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`)
return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used.
"""
url_params = QueryDict(mutable=True)
url_params.update(kwargs)
url_params = dict_to_querydict(kwargs)
url_params['return_url'] = return_url or context['request'].path
return {
'viewname': viewname,

View File

@@ -11,8 +11,9 @@ from django.core import serializers
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django.http import QueryDict
from django.utils.html import escape
from django.utils import timezone
from django.utils.datastructures import MultiValueDict
from django.utils.html import escape
from django.utils.timezone import localtime
from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel
@@ -231,6 +232,19 @@ def dict_to_filter_params(d, prefix=''):
return params
def dict_to_querydict(d, mutable=True):
"""
Create a QueryDict instance from a regular Python dictionary.
"""
qd = QueryDict(mutable=True)
for k, v in d.items():
item = MultiValueDict({k: v}) if isinstance(v, (list, tuple, set)) else {k: v}
qd.update(item)
if not mutable:
qd._mutable = False
return qd
def normalize_querydict(querydict):
"""
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
@@ -505,6 +519,8 @@ def clean_html(html, schemes):
"h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"],
"a": ["href", "title"],
"img": ["src", "title", "alt"],
"th": ["align"],
"td": ["align"],
}
return bleach.clean(

View File

@@ -1,7 +1,7 @@
bleach==6.0.0
boto3==1.26.156
Django==4.1.9
django-cors-headers==4.1.0
boto3==1.28.14
Django==4.1.10
django-cors-headers==4.2.0
django-debug-toolbar==4.1.0
django-filter==23.2
django-graphiql-debug-toolbar==0.2.0
@@ -9,27 +9,27 @@ django-mptt==0.14
django-pglocks==1.0.4
django-prometheus==2.3.1
django-redis==5.3.0
django-rich==1.6.0
django-rich==1.7.0
django-rq==2.8.1
django-tables2==2.5.3
django-tables2==2.6.0
django-taggit==4.0.0
django-timezone-field==5.1
djangorestframework==3.14.0
drf-spectacular==0.26.2
drf-spectacular-sidecar==2023.6.1
drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.7.1
dulwich==0.21.5
feedparser==6.0.10
graphene-django==3.0.0
gunicorn==20.1.0
gunicorn==21.2.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.1.16
mkdocs-material==9.1.21
mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0
Pillow==9.5.0
Pillow==10.0.0
psycopg2-binary==2.9.6
PyYAML==6.0
sentry-sdk==1.25.1
PyYAML==6.0.1
sentry-sdk==1.28.1
social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3