Compare commits

...

102 Commits

Author SHA1 Message Date
Jeremy Stretch
9dab3a0d79 Merge pull request #12946 from netbox-community/develop
Release v3.5.4
2023-06-20 14:24:34 -04:00
Jeremy Stretch
54622b5f92 Release v3.5.4 2023-06-20 13:56:09 -04:00
Jeremy Stretch
cdce500d90 Changelog for #12474, #12828, #12845, #12865, #12885, #12914 2023-06-15 16:15:15 -04:00
Luke Anderson
e11991c7a4 Fix #12865 - Include Add Nav Buttons for Report and Script Objects (#12909) 2023-06-15 15:04:08 -04:00
Jeremy Stretch
6ef333ea68 Fixes #12885: Permit mounting of devices in U100 (#12901)
* Fixes #12885: Permit mounting of devices in U100

* Define a RACK_U_HEIGHT_MAX constant
2023-06-15 15:00:45 -04:00
Jeremy Stretch
7fc69f3945 Fixes #12914: Clear stored ordering from user config when cleared by request 2023-06-15 14:59:52 -04:00
Jeremy Stretch
8aeb31751a Fixes #12845: Fix pagination of related IP addresses table 2023-06-15 13:07:51 -04:00
Arthur
0b2162569f 12474 update cable terminations when moving location between sites 2023-06-15 08:53:06 -04:00
Abhimanyu Saharan
93175888f0 add color to ChangeActionChoices #12828 2023-06-15 08:48:36 -04:00
Jeremy Stretch
4d686e8162 Changelog for #12622, #12682, #12818, #12822, #12847 2023-06-14 13:54:40 -04:00
Dillon Henschen
0e873a01b8 Closes #12622: Fix assigning VLAN without site to Prefix (#12784)
* Issue #12622: Fix creating Prefix using VLAN without site

* Issue #12622: Fix importing Prefix using VLAN without site

This commit also adds tests to verify the import changes implemented
in this commit.

* Issue #12622: Cleanup code to filter allowed VLANs on a prefix import

* Closes #12622: Switch to VLAN selector dialog when creating Prefix
2023-06-14 13:49:00 -04:00
Jeremy Stretch
f7b0e48a09 Merge pull request #12864 from sudheesh001/fix/12847-include-adds
Fixes #12847 - Include Missing Add buttons to Views
2023-06-14 13:33:18 -04:00
Sudheesh Singanamalla
c5f71c0c19 Fixes #12847 - Include Missing Add buttons to Views
Signed-off-by: Sudheesh Singanamalla <sudheesh@cs.washington.edu>
2023-06-14 10:05:43 -07:00
Jeremy Stretch
36e0bf0490 Merge pull request #12893 from netbox-community/feat/12824-doc
Fixes typo in register_model_view docstring
2023-06-14 08:39:28 -04:00
Jeremy Stretch
28b939c001 Merge pull request #12894 from netbox-community/fix/12822-link-encode
Change link parsing from quote_plus to quote
2023-06-14 08:38:11 -04:00
Jeremy Stretch
55e31ef984 Merge pull request #12896 from netbox-community/fix/12818-perm
Fix permission
2023-06-14 07:57:32 -04:00
Jeremy Stretch
85e351146d Merge pull request #12897 from netbox-community/fix/12682-openapi-connected-device
Fix connected device api schema
2023-06-14 07:56:10 -04:00
Abhimanyu Saharan
d03bfe89c0 fix connected device api schema #12682 2023-06-14 15:45:07 +05:30
Abhimanyu Saharan
c8cbced55e fix permission #12818 2023-06-14 14:43:18 +05:30
Abhimanyu Saharan
928a34674e change link parsing from quote_plus to quote #12822 2023-06-14 14:16:04 +05:30
Abhimanyu Saharan
96cf95d176 fixes typo in register_model_view docstring #12824 2023-06-14 14:06:23 +05:30
jeremystretch
2e9586523f Changelog for #12687, #12838, #12850, #12862 2023-06-13 15:47:40 -04:00
Jeremy Stretch
a81924ac0f Merge pull request #12863 from sudheesh001/fix/12862-connection-sidebar-add
Fixes #12862 - Add Button for Wireless Links in Sidebar
2023-06-13 09:19:32 -04:00
Jeremy Stretch
74c1f7a176 Merge pull request #12874 from ITJamie/broadcast_exceptions
assigning broadcast ip error fixes for ipv6 and /31/32
2023-06-13 08:57:32 -04:00
Jamie Murphy
22a0ce3f76 broadcast error fixes for ipv6 and /31/32 2023-06-12 21:01:43 +01:00
Jeremy Stretch
43235f143d Merge pull request #12839 from candlerb/candlerb/12838
Round rack power utilization to nearest 0.1%
2023-06-12 08:12:17 -04:00
Jeremy Stretch
e7851399c6 Merge pull request #12857 from netbox-community/fix/12850-contacts-table
fix contact assignment table modal
2023-06-12 08:09:17 -04:00
Sudheesh Singanamalla
82cd6c5f4c Fixes #12862 - Add Button for Wireless Links in Sidebar
Signed-off-by: Sudheesh Singanamalla <sudheesh@cs.washington.edu>
2023-06-11 12:17:32 -07:00
Abhimanyu Saharan
210879d380 fix contact assignment table modal 2023-06-11 16:50:48 +05:30
Brian Candler
01d9e0afb6 Round rack power utilization to nearest 0.1%
Fixes #12838
2023-06-08 14:31:46 +01:00
jeremystretch
4a88d5e3d9 PRVB 2023-06-02 15:42:34 -04:00
Jeremy Stretch
9fb52be85c Merge pull request #12805 from netbox-community/develop
Release v3.5.3
2023-06-02 15:37:20 -04:00
jeremystretch
46d1d5a44a Release v3.5.3 2023-06-02 14:17:01 -04:00
jeremystretch
dee4aec62d Fixes #12779: Correct arithmetic for converting inches to meters 2023-06-01 13:21:15 -04:00
jeremystretch
9f70407c7d Remove survey link 2023-06-01 13:00:09 -04:00
jeremystretch
852026bf7b Changelog for #7503, #9876, #12015, #12538, #12762 2023-05-31 16:35:06 -04:00
Abhimanyu Saharan
e7f689bc52 Fixes incorrectly handled type error when list of objects is found in data (#12593)
* fixes incorrectly handled type error when list of objects is found in data #9876

* fixes incorrectly handled type error when list of objects is found in data #9876

* fixes incorrectly handled type error when list of objects is found in data #9876
2023-05-31 15:44:59 -04:00
Daniel Sheppard
1349a25e34 Update missing changelog 2023-05-31 14:30:40 -05:00
Abhimanyu Saharan
dbd3c6de24 Fixes return_url for image attachment (#12721)
* fixes return_url for image attachment #12538

* simplified conditions

* handle nonetype error

* fixed request check

* Introduce htmx_table template tag for embedding HTMX-backed object tables

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-31 15:22:37 -04:00
Arthur
3e77daff01 12767 pin graphene-django version 2023-05-31 12:04:34 -04:00
Abhimanyu Saharan
bd88ee7063 Adds device type and role to device component filter (#12504)
* adds device type and role to device component filter #12015

* changes as per review

* Add filterset tests for device type & role filters

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-31 09:59:22 -04:00
Daniel Sheppard
a9b0b49ef9 Fixes #12702 - Adds widget to FrontPortTemplateCreateForm 2023-05-31 08:49:23 -05:00
Arthur Hanson
8b051ea2f3 7503 do device validate-create in serial (#12222)
* 7503 do device validate-create in serial

* 7503 fix single instance

* 7503 atomic transaction

* 7503 fix return data for bulk operations

* 7503 add test

* Move sequential creation logic to a mixin

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-31 09:06:09 -04:00
jeremystretch
bca9d0fa8a Closes #12599: Apply filter parameters to links in object count dashboard widgets 2023-05-30 16:31:34 -04:00
jeremystretch
9b8ab1c1f7 Fixes #12742: Object counts dashboard widget should support URL-compatible query filters 2023-05-30 15:44:43 -04:00
jeremystretch
b3bd03a1e9 Fixes #12715: Use contact assignments table to display the contacts assigned to an object 2023-05-30 14:51:16 -04:00
jeremystretch
18c863e393 Changelog for #11539, #12370, #12470, #12562, #12597, #12627, #12745 2023-05-30 09:52:14 -04:00
Abhimanyu Saharan
d7ca453f26 Adds hide-if-unset to custom field (#12723)
* adds hide-if-unset to custom field #12597

* moved hide logic from template to python

* fix indentation

* Update logic for omit_hidden under get_custom_fields()

* Update docs

* Account for False values

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-30 09:42:37 -04:00
Abhimanyu Saharan
9b9a559e0c Adds image preview back on the table (#12739)
* adds image preview on image attachment #12627

* adds bootstrap initialization for hx-trigger=load #12627

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-30 09:41:32 -04:00
kkthxbye-code
1f71d3570a Escape text passed as display values to slim-select 2023-05-30 09:09:15 -04:00
Abhimanyu Saharan
5a5fcf7d37 Changes render config card with accordian (#12724)
* changes render config card with accordian #12470

* fixed indentation #12470

* Use -flush CSS class to reduce whitespace

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-26 09:32:58 -04:00
Abhimanyu Saharan
5869894a48 Adds ip to failed logs (#12725)
* adds ip to failed logs #12562

* added additional logging when client ip cannot be determined
2023-05-26 08:46:49 -04:00
Abhimanyu Saharan
e2f9a3c07a fixes contact assignments filter to include parent content type #12730 2023-05-26 08:42:04 -04:00
jeremystretch
b64b19a3f4 Fixes #11934: Prevent reassignment of an IP address designated as primary for its parent object 2023-05-25 16:42:24 -04:00
Jeremy Stretch
24a51dd86e Fixes #11539: Use BooleanFilter for 'empty' lookups (#11784)
* Use BooleanFilter for 'empty' lookups

* Always use BooleanFilter for 'empty' lookups

* Restore Empty lookup logic
2023-05-25 15:20:08 -04:00
jeremystretch
bf1c191b2e Fixes #12694: Strip leading & trailing whitespace from custom link URL & text 2023-05-24 15:45:24 -04:00
jeremystretch
b31b086a4d Link to the plugin ideas board 2023-05-23 15:34:03 -04:00
jeremystretch
6160e03426 PRVB 2023-05-22 17:00:29 -04:00
Jeremy Stretch
c9b79ca579 Merge pull request #12681 from netbox-community/develop
Release v3.5.2
2023-05-22 16:54:03 -04:00
jeremystretch
fbc7811f56 Release v3.5.2 2023-05-22 16:24:30 -04:00
Austin de Coup-Crank
005e3fd692 Closes #9068: validate addresses assigned to interfaces (#12618)
* Begin logic

* Closes #9068: Disallow assigning bcast/networks to interfaces

* Allow net IDs in /31, /32, /127, /128

* linting error

* Implement requested changes

* Condensed the "if" logic a bit

---------

Co-authored-by: Austin de Coup-Crank <decoupca@moodys.com>
Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-22 16:16:17 -04:00
Dillon Henschen
078893e034 Closes #11619: Include VLANs with a null site in query during bulk interface edit for Devices > DEVICE COMPONENTS > Interfaces (#12659)
* Closes #11619: Allow VLANs without a site during multi-port edits

This commit allows users to be able to select VLANs without a site assignment
during bulk interfaces edits under Devices > DEVICE COMPONENTS > Interfaces.

Prior to this commit, only VLANs that were assigned the same site as the device
were available for selection.

* Replace 'null' with FILTERS_NULL_CHOICE_VALUE constant

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-22 15:37:31 -04:00
Daniel W. Anner
80fc8db514 Adding interface type 200gbase-x-qsfpdd 2023-05-22 13:11:29 -04:00
jeremystretch
fa3bedb947 Fixes #12642: Fix bulk tenant assignment via cluster import form 2023-05-22 13:07:40 -04:00
jeremystretch
c8d9a3b4eb Changelog for #12327, #12548, #12594, #12605, #12629 2023-05-18 14:34:13 -04:00
Austin de Coup-Crank
311dce0b5f Closes #12605: Add LX.5 port type 2023-05-18 14:25:51 -04:00
neope
23b21246f0 Adding CDFP and CFP8 400GE connectors (#12646)
* Adding CDFP and CFP8 400GE connectors

* Update choices.py

typo on CFP8
2023-05-18 14:21:32 -04:00
Arthur
92c49669f9 12548 add prefetch_related for l2vpn and vdcs to interface api 2023-05-18 14:18:55 -04:00
Abhimanyu Saharan
2204735e9f Adds rq retry options (#12588)
* adds rq retry options #12327

* Clean up docs; disable retries of failed jobs by default

* Pass a Retry object only if RQ_RETRY_MAX is non-zero

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-16 14:10:44 -04:00
Abhimanyu Saharan
0df6a5793a Adds maintenance exempt paths (#12592)
* adds maintenance exempt paths #11233

* adds maintenance exempt paths #11233

* Rename method & remove login/logout from exempt paths

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-16 11:35:21 -04:00
Arthur
eeb15ab5d1 12594 add config context to object count / list widget 2023-05-16 10:52:23 -04:00
jeremystretch
d5be59ef67 Update README 2023-05-16 08:39:05 -04:00
kkthxbye
0ad88e2431 Changed docs to reflect the new URL for the dynamic API documentation 2023-05-16 08:19:57 +02:00
jeremystretch
c65b2a080f Changelog for #11017, #12468 2023-05-15 09:13:11 -04:00
Devon Mar
0f44f7eb20 Use .font-monospace instead of .text-monospace 2023-05-15 08:28:59 -04:00
Austin de Coup-Crank
e40e9cb406 Closes #11017: increase maximum power draw (#12587)
* Convert power draw/max draw to PositiveIntegerField

* Closes #11017: Increase maximum power draw

* Rename migration file for clarity

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 16:10:12 -04:00
Arthur Hanson
21f4761335 12468 disallow double underscores in custom field names (#12523)
* 12468 disallow double underscores in custom field names

* 12468 disallow double underscores in custom field names

* 12468 review changes

* 12468 correct migration

* 12468 use inverse match

* 12468 use inverse match

* Add test for invalid custom field names

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 16:08:57 -04:00
jeremystretch
39fd64b2ef Fixes #12570: Disable ordering of synchronized object tables by the synced attribute 2023-05-12 11:08:32 -04:00
jeremystretch
567285d36a Changelog for #7671, #10686, #11233, #11559, #12554 2023-05-12 11:00:33 -04:00
jeremystretch
ff874a24dd #7671: Document REMOTE_AUTH_AUTO_CREATE_GROUPS config parameter 2023-05-12 10:56:36 -04:00
Abhimanyu Saharan
9b80ec22ba Adds db read-only middleware (#12490)
* adds db read-only middleware #11233

* fixed attribute error

* replaces getattr with get_config
2023-05-12 10:50:51 -04:00
Jon Schewe
cc0c985fec Feature/remote group autocreate (#12394)
* Add REMOTE_AUTH_AUTOCREATE_GROUPS

When REMOTE_AUTH_AUTOCREATE_GROUPS is True, Netbox will create groups
referenced in the REMOTE_AUTH_GROUP_HEADER that don't exist in the
database.

Closes #7671

* Fix naming of parameter

Apply the fix requested by kkthxbye-code in https://github.com/netbox-community/netbox/pull/8603

---------

Co-authored-by: Lars Kellogg-Stedman <lars@oddbit.com>
2023-05-12 10:35:09 -04:00
Abhimanyu Saharan
4eb5e90ccc Adds contact tabs (#12460)
* adds contact tabs #11599

* fixed lint issues

* changes as per review

* changes as per review

* replaces generic object template with base template
2023-05-12 10:26:26 -04:00
Abhimanyu Saharan
e71a98499f Adds BANNER_MAINTENANCE config (#12555)
* adds BANNER_MAINTENANCE config #12554

* changes as per review

* lint fix

* Fix admin form field widget

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-12 09:54:59 -04:00
Daniel Sheppard
011a936a56 Fixes #10686 - Import cables using VC master device (#12551)
* Allow importing cables against master device for subordinate device interfaces

* Add tests
2023-05-12 09:07:51 -04:00
Abhimanyu Saharan
556beeee6c Updates doc for reports and scripts permission (#12565)
* updates doc for script permission #12557

* updates doc for reports permission #12557
2023-05-12 09:04:56 -04:00
jeremystretch
b7f028fba3 Fixes #12550: Fix rear port selection widget under front port creation form 2023-05-10 10:44:01 -04:00
jeremystretch
2d0ac213c7 Changelog for #11670, #11900, #12131, #12233, #12286, #12323 2023-05-10 09:39:25 -04:00
Abhimanyu Saharan
6b19f15a7b Moves related ips to a tab (#12502)
* moves related ips to a tab #12233

* Refactor IP address templates to use a base template

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 12:49:13 -04:00
Abhimanyu Saharan
57156f0e94 Adds stroke to the reservation (#12506)
* adds stroke to the reservation #11900

* fixed right side border

* Tweak reserved stroke style & add constants for colors

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 10:21:23 -04:00
Abhimanyu Saharan
4e49f4a434 Adds tooltip on custom field (#12505)
* adds tooltip on custom field #12131

* adds description field check

* fixed field name

* updated code to match the panel

* added escape filter on description
2023-05-09 10:20:02 -04:00
Dillon Henschen
c55c14ea4c Closes #11670: Add ability to optionally import DeviceType and ModuleType weight (#12512)
* 11670: Add optional weight to DeviceType import

This is 1 of 2 commits to address issue #11670

To maintain consistency, the import design of the DeviceType weight follows the
same pattern used for importing weight and weight units in DCIM Racks.

* Closes #11670: Add weight to ModuleType import

This is commit 2 of 2 to address and close #11670.

To maintain consistency, the import design of the ModuleType weight follows the
same pattern used for importing weight and weight units in DCIM Racks.

* Merge tests; misc cleanup

---------

Co-authored-by: jeremystretch <jstretch@netboxlabs.com>
2023-05-09 09:59:42 -04:00
Abhimanyu Saharan
e1b7a3aeb6 Replaced device type weight with device total weight (#12522)
* replaced device type weight with device total weight #12286

* replaced device type weight with device total weight #12286

* Update netbox/templates/dcim/device.html

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-09 09:35:00 -04:00
Abhimanyu Saharan
2b2c559a37 updates ldap doc for centos #12447 2023-05-09 08:32:22 -04:00
Abhimanyu Saharan
1af3ba9496 Adds full_clean in examples (#12527)
* adds full_clean in examples #11689

* removes extra info
2023-05-09 08:31:50 -04:00
Abhimanyu Saharan
cb6852bf7a adds CXP (100GE) #12323 2023-05-09 08:28:58 -04:00
Abhimanyu Saharan
259d0e96f2 Adds dimensions card to device view (#12509)
* adds dimensions card to device view #12286

* Update netbox/templates/dcim/device.html

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

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-05-08 08:56:28 -04:00
jeremystretch
9eeca06115 #12498: Annotate option to set MAPS_URL=None 2023-05-05 15:26:22 -04:00
jeremystretch
da781b8d28 Changelog for #12223, #12498 2023-05-05 15:25:08 -04:00
Abhimanyu Saharan
896b19eaa3 adds parent device and bay position to table #12223 2023-05-05 15:23:28 -04:00
Abhimanyu Saharan
12bef7623c disables map button when MAP_URL is none #12498 2023-05-05 15:04:36 -04:00
jeremystretch
e96cfadd22 PRVB 2023-05-05 12:58:09 -04:00
113 changed files with 1419 additions and 384 deletions

View File

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

View File

@@ -3,10 +3,13 @@ blank_issues_enabled: false
contact_links:
- name: 📖 Contributing Policy
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
about: "Please read through our contributing policy before opening an issue or pull request"
about: "Please read through our contributing policy before opening an issue or pull request."
- name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead"
about: "If you're just looking for help, try starting a discussion instead."
- name: 💡 Plugin Idea
url: https://plugin-ideas.netbox.dev
about: "Have an idea for a plugin? Head over to the ideas board!"
- name: 💬 Community Slack
url: https://netdev.chat/
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems"
url: https://netdev.chat
about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems."

View File

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

View File

@@ -1,11 +1,10 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
The premiere source of truth powering network automation
<p>The premiere source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
<p></p>
</div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions,

View File

@@ -84,7 +84,8 @@ feedparser
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django/releases
graphene_django
# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
graphene_django==3.0.0
# WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html

View File

@@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f
```
>>> lab1 = Site.objects.get(pk=7)
>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1)
>>> myvlan.full_clean()
>>> myvlan.save()
```
Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.)
```
>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save()
```
To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again.
```
@@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c
>>> vlan.name
'MyNewVLAN'
>>> vlan.name = 'BetterName'
>>> vlan.full_clean()
>>> vlan.save()
>>> VLAN.objects.get(pk=1280).name
'BetterName'

View File

@@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo
---
## BANNER_MAINTENANCE
!!! tip "Dynamic Configuration Parameter"
!!! note
This parameter was added in NetBox v3.5.
This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed.
---
## BANNER_TOP
!!! tip "Dynamic Configuration Parameter"
@@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever
Default: `https://maps.google.com/?q=` (Google Maps)
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it.
This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI.
---
@@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne
Default: `300`
The maximum execution time of a background task (such as running a custom script), in seconds.
---
## RQ_RETRY_INTERVAL
!!! note
This parameter was added in NetBox v3.5.
Default: `60`
This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour.
---
## RQ_RETRY_MAX
!!! note
This parameter was added in NetBox v3.5.
Default: `0` (retries disabled)
The maximum number of times a background task will be retried before being marked as failed.

View File

@@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo
---
## REMOTE_AUTH_AUTO_CREATE_GROUPS
Default: `False`
If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.)
---
## REMOTE_AUTH_AUTO_CREATE_USER
Default: `False`

View File

@@ -378,6 +378,7 @@ class NewBranchScript(Script):
slug=slugify(data['site_name']),
status=SiteStatusChoices.STATUS_PLANNED
)
site.full_clean()
site.save()
self.log_success(f"Created new site: {site}")
@@ -391,6 +392,7 @@ class NewBranchScript(Script):
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
)
switch.full_clean()
switch.save()
self.log_success(f"Created new switch: {switch}")

View File

@@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
```
sudo adduser --system --group netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
```
=== "CentOS"
@@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
sudo groupadd --system netbox
sudo adduser --system -g netbox netbox
sudo chown --recursive netbox /opt/netbox/netbox/media/
sudo chown --recursive netbox /opt/netbox/netbox/reports/
sudo chown --recursive netbox /opt/netbox/netbox/scripts/
```
## Configuration

View File

@@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev
On CentOS:
```no-highlight
sudo yum install -y openldap-devel
sudo yum install -y openldap-devel python3-devel
```
### Install django-auth-ldap

View File

@@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object
## Interactive Documentation
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`.
## Endpoint Hierarchy

View File

@@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values.
Controls how and whether the custom field is displayed within the NetBox user interface.
| Option | Description |
|------------|--------------------------------------|
| Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI |
| Option | Description |
|-------------------|--------------------------------------------------|
| Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI |
| Hidden (if unset) | Display in the UI only when a value has been set |
### Default

View File

@@ -1,5 +1,95 @@
# NetBox v3.5
## v3.5.4 (2023-06-20)
### Enhancements
* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
### Bug Fixes
* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
---
## v3.5.3 (2023-06-02)
### Enhancements
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
### Bug Fixes
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
---
## v3.5.2 (2023-05-22)
### Enhancements
* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use
* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces
* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws
* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled
* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views
* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import
* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations
* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views
* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list
* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab
* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view
* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type
* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs
* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty
* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments
* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner
* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types
* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types
* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type
### Bug Fixes
* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables
* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit
* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores
* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form
* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute
* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget
* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form
---
## v3.5.1 (2023-05-05)
### Enhancements

View File

@@ -1,10 +1,10 @@
from django.contrib import messages
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render
from dcim.views import PathTraceView
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.utils import count_related
from utilities.views import register_model_view
@@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable
@register_model_view(Provider, 'contacts')
class ProviderContactsView(ObjectContactsView):
queryset = Provider.objects.all()
#
# ProviderAccounts
#
@@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderAccountTable
@register_model_view(ProviderAccount, 'contacts')
class ProviderAccountContactsView(ObjectContactsView):
queryset = ProviderAccount.objects.all()
#
# Provider networks
#
@@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView):
})
@register_model_view(Circuit, 'contacts')
class CircuitContactsView(ObjectContactsView):
queryset = Circuit.objects.all()
#
# Circuit terminations
#

View File

@@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet):
"""
Enqueue a job to synchronize the DataSource.
"""
if not request.user.has_perm('extras.sync_datasource'):
if not request.user.has_perm('core.sync_datasource'):
raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.")
datasource = get_object_or_404(DataSource, pk=pk)

View File

@@ -16,7 +16,7 @@ from extras.utils import FeatureQuery
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model
from utilities.rqworker import get_queue_for_model, get_rq_retry
__all__ = (
'Job',
@@ -219,5 +219,6 @@ class Job(models.Model):
event=event,
data=self.data,
timestamp=str(timezone.now()),
username=self.user.username
username=self.user.username,
retry=get_rq_retry()
)

View File

@@ -1,12 +1,12 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@@ -14,7 +14,6 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
@@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules
#
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
@@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
queryset = Interface.objects.prefetch_related(
'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans',
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags'
'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations',
'vdcs',
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@@ -640,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet):
def get_view_name(self):
return "Connected Device Locator"
@extend_schema(responses={200: OpenApiTypes.OBJECT})
@extend_schema(
parameters=[_device_param, _interface_param],
responses={200: serializers.DeviceSerializer}
)
def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name)

View File

@@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP = '100gbase-x-cfp'
TYPE_100GE_CFP2 = '100gbase-x-cfp2'
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp'
TYPE_400GE_CFP8 = '400gbase-x-cfp8'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
@@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_400GE_CDFP, 'CDFP (400GE)'),
(TYPE_400GE_CFP8, 'CPF8 (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
)
@@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet):
TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc'
TYPE_LX5 = 'lx5'
TYPE_LX5_PC = 'lx5-pc'
TYPE_LX5_UPC = 'lx5-upc'
TYPE_LX5_APC = 'lx5-apc'
TYPE_SPLICE = 'splice'
TYPE_CS = 'cs'
TYPE_SN = 'sn'
@@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet):
(TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'),
(TYPE_LX5, 'LX.5'),
(TYPE_LX5_PC, 'LX.5/PC'),
(TYPE_LX5_UPC, 'LX.5/UPC'),
(TYPE_LX5_APC, 'LX.5/APC'),
(TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'),

View File

@@ -11,6 +11,7 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
#
RACK_U_HEIGHT_DEFAULT = 42
RACK_U_HEIGHT_MAX = 100
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30

View File

@@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label=_('Device (name)'),
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type',
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type__model',
queryset=DeviceType.objects.all(),
to_field_name='model',
label=_('Device type (model)'),
)
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),

View File

@@ -1,4 +1,5 @@
from django import forms
from django.conf import settings
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from timezone_field import TimeZoneFormField
@@ -1292,8 +1293,13 @@ class InterfaceBulkEditForm(
break
if site is not None:
self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk)
self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk)
# Query for VLANs assigned to the same site and VLANs with no site assigned (null).
self.fields['untagged_vlan'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True

View File

@@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('The default platform for devices of this type (optional)')
)
weight = forms.DecimalField(
required=False,
help_text=_('Device weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for device weight')
)
class Meta:
model = DeviceType
fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'comments',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
]
@@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
weight = forms.DecimalField(
required=False,
help_text=_('Module weight'),
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for module weight')
)
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'comments']
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
class DeviceRoleImportForm(NetBoxModelImportForm):
@@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm):
model = content_type.model_class()
try:
termination_object = model.objects.get(device=device, name=name)
if device.virtual_chassis and device.virtual_chassis.master == device and \
model.objects.filter(device=device, name=name).count() == 0:
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
else:
termination_object = model.objects.get(device=device, name=name)
if termination_object.cable is not None:
raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
except ObjectDoesNotExist:

View File

@@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Virtual Chassis')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
device_role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
)
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'
'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$device_role_id'
},
label=_('Device')
)
@@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
'device_id', 'vdc_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
vdc_id = DynamicModelMultipleChoiceField(
@@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')),
)
model = FrontPort
@@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
('Cable', ('cabled', 'occupied')),
)
type = forms.MultipleChoiceField(
@@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
@@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),

View File

@@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
installed_device = forms.ModelChoiceField(
queryset=Device.objects.all(),
label=_('Child Device'),
help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.")
help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.")
)
def __init__(self, device_bay, *args, **kwargs):

View File

@@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
@@ -242,6 +243,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortForm to omit rear_port_position

View File

@@ -18,6 +18,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='device',
name='position',
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]),
field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 4.1.9 on 2023-05-12 18:46
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0171_cabletermination_change_logging'),
]
operations = [
migrations.AlterField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AlterField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
]

View File

@@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel):
choices=PowerPortTypeChoices,
blank=True
)
maximum_draw = models.PositiveSmallIntegerField(
maximum_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
allocated_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],

View File

@@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
blank=True,
help_text=_('Physical port type')
)
maximum_draw = models.PositiveSmallIntegerField(
maximum_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveSmallIntegerField(
allocated_draw = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1)],

View File

@@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin):
'subdevice_role': self.subdevice_role,
'airflow': self.airflow,
'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
}
# Component templates
@@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin):
'model': self.model,
'part_number': self.part_number,
'comments': self.comments,
'weight': float(self.weight) if self.weight is not None else None,
'weight_unit': self.weight_unit,
}
# Component templates
@@ -564,7 +568,7 @@ class Device(PrimaryModel, ConfigContextModel):
decimal_places=1,
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
verbose_name='Position (U)',
help_text=_('The lowest-numbered unit occupied by the device')
)

View File

@@ -126,7 +126,7 @@ class Rack(PrimaryModel, WeightMixin):
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)],
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
desc_units = models.BooleanField(
@@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin):
powerport.get_power_draw()['allocated'] for powerport in powerports
])
return int(allocated_draw / available_power_total * 100)
return round(allocated_draw / available_power_total * 100, 1)
@cached_property
def total_weight(self):

View File

@@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs):
Rack.objects.filter(location__in=locations).update(site=instance.site)
Device.objects.filter(location__in=locations).update(site=instance.site)
PowerPanel.objects.filter(location__in=locations).update(site=instance.site)
CableTermination.objects.filter(_location__in=locations).update(_site=instance.site)
@receiver(post_save, sender=Rack)

View File

@@ -22,6 +22,11 @@ __all__ = (
'RackElevationSVG',
)
GRADIENT_RESERVED = '#b0b0ff'
GRADIENT_OCCUPIED = '#d7d7d7'
GRADIENT_BLOCKED = '#ffc0c0'
STROKE_RESERVED = '#4d4dff'
def get_device_name(device):
if device.virtual_chassis:
@@ -132,9 +137,9 @@ class RackElevationSVG:
drawing.defs.add(drawing.style(css_file.read()))
# Add gradients
RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff')
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED)
RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED)
RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED)
return drawing
@@ -246,13 +251,13 @@ class RackElevationSVG:
coords = self._get_device_coords(segment[0], u_height)
coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1])
size = (
self.margin_width,
self.margin_width - 3,
u_height * self.unit_height
)
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2)
)
self.drawing.add(link)

View File

@@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
config_template = tables.Column(
linkify=True
)
parent_device = tables.Column(
verbose_name='Parent Device',
linkify=True,
accessor='parent_bay__device'
)
device_bay_position = tables.Column(
verbose_name='Position (Device Bay)',
accessor='parent_bay',
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:device_list'
@@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = models.Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2),
)
DeviceType.objects.bulk_create(device_types)
@@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
def test_rack_fit(self):
"""
Check that creating multiple devices with overlapping position fails.
"""
device = Device.objects.first()
device_type = DeviceType.objects.all()[1]
data = [
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 7',
'rack': device.rack.pk,
'face': 'front',
'position': 1
},
{
'device_type': device_type.pk,
'device_role': device.device_role.pk,
'site': device.site.pk,
'name': 'Test Device 8',
'rack': device.rack.pk,
'face': 'front',
'position': 2
}
]
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module

View File

@@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class DeviceComponentFilterSetTests:
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Region.objects.all()
filterset = RegionFilterSet
@@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsolePort.objects.all()
filterset = ConsolePortFilterSet
@@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ConsoleServerPort.objects.all()
filterset = ConsoleServerPortFilterSet
@@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerPort.objects.all()
filterset = PowerPortFilterSet
@@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = PowerOutlet.objects.all()
filterset = PowerOutletFilterSet
@@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = Interface.objects.all()
filterset = InterfaceFilterSet
@@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all()
filterset = FrontPortFilterSet
@@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = RearPort.objects.all()
filterset = RearPortFilterSet
@@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = ModuleBay.objects.all()
filterset = ModuleBayFilterSet
@@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
queryset = DeviceBay.objects.all()
filterset = DeviceBayFilterSet
@@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
Site(name='Site X', slug='site-x'),
))
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_types = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
@@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Manufacturer.objects.bulk_create(manufacturers)
device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device_types = (
DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
)
DeviceType.objects.bulk_create(device_types)
device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
)
DeviceRole.objects.bulk_create(device_roles)
regions = (
Region(name='Region 1', slug='region-1'),
@@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_type(self):
device_types = DeviceType.objects.all()[:2]
params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_type': [device_types[0].model, device_types[1].model]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device_role(self):
device_role = DeviceRole.objects.all()[:2]
params = {'device_role_id': [device_role[0].pk, device_role[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'device_role': [device_role[0].slug, device_role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}

View File

@@ -681,11 +681,15 @@ class DeviceTypeTestCase(
"""
IMPORT_DATA = """
manufacturer: Generic
default_platform: Platform
model: TEST-1000
slug: test-1000
default_platform: Platform
u_height: 2
is_full_depth: false
airflow: front-to-rear
subdevice_role: parent
weight: 10
weight_unit: kg
comments: Test comment
console-ports:
- name: Console Port 1
@@ -794,8 +798,16 @@ inventory-items:
self.assertHttpStatus(response, 200)
device_type = DeviceType.objects.get(model='TEST-1000')
self.assertEqual(device_type.comments, 'Test comment')
self.assertEqual(device_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(device_type.default_platform.pk, platform.pk)
self.assertEqual(device_type.slug, 'test-1000')
self.assertEqual(device_type.u_height, 2)
self.assertFalse(device_type.is_full_depth)
self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR)
self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT)
self.assertEqual(device_type.weight, 10)
self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM)
self.assertEqual(device_type.comments, 'Test comment')
# Verify all of the components were created
self.assertEqual(device_type.consoleporttemplates.count(), 3)
@@ -1019,6 +1031,8 @@ class ModuleTypeTestCase(
IMPORT_DATA = """
manufacturer: Generic
model: TEST-1000
weight: 10
weight_unit: lb
comments: Test comment
console-ports:
- name: Console Port 1
@@ -1082,7 +1096,8 @@ front-ports:
"""
# Create the manufacturer
Manufacturer(name='Generic', slug='generic').save()
manufacturer = Manufacturer(name='Generic', slug='generic')
manufacturer.save()
# Add all required permissions to the test user
self.add_permissions(
@@ -1105,6 +1120,9 @@ front-ports:
self.assertHttpStatus(response, 200)
module_type = ModuleType.objects.get(model='TEST-1000')
self.assertEqual(module_type.manufacturer.pk, manufacturer.pk)
self.assertEqual(module_type.weight, 10)
self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND)
self.assertEqual(module_type.comments, 'Test comment')
# Verify all the components were created
@@ -2889,6 +2907,7 @@ class CableTestCase(
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer)
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
vc = VirtualChassis.objects.create(name='Virtual Chassis')
devices = (
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
@@ -2898,6 +2917,10 @@ class CableTestCase(
)
Device.objects.bulk_create(devices)
vc.members.set((devices[0], devices[1], devices[2]))
vc.master = devices[0]
vc.save()
interfaces = (
Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
@@ -2911,6 +2934,10 @@ class CableTestCase(
Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
)
Interface.objects.bulk_create(interfaces)
@@ -2943,6 +2970,8 @@ class CableTestCase(
"Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1",
"Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2",
"Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3",
"Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4",
"Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5",
)
cls.csv_update_data = (

View File

@@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
@@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView):
table = tables.RegionTable
@register_model_view(Region, 'contacts')
class RegionContactsView(ObjectContactsView):
queryset = Region.objects.all()
#
# Site groups
#
@@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteGroupTable
@register_model_view(SiteGroup, 'contacts')
class SiteGroupContactsView(ObjectContactsView):
queryset = SiteGroup.objects.all()
#
# Sites
#
@@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView):
table = tables.SiteTable
@register_model_view(Site, 'contacts')
class SiteContactsView(ObjectContactsView):
queryset = Site.objects.all()
#
# Locations
#
@@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView):
table = tables.LocationTable
@register_model_view(Location, 'contacts')
class LocationContactsView(ObjectContactsView):
queryset = Location.objects.all()
#
# Rack roles
#
@@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView):
table = tables.RackTable
@register_model_view(Rack, 'contacts')
class RackContactsView(ObjectContactsView):
queryset = Rack.objects.all()
#
# Rack reservations
#
@@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView):
table = tables.ManufacturerTable
@register_model_view(Manufacturer, 'contacts')
class ManufacturerContactsView(ObjectContactsView):
queryset = Manufacturer.objects.all()
#
# Device types
#
@@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView):
table = tables.DeviceTable
@register_model_view(Device, 'contacts')
class DeviceContactsView(ObjectContactsView):
queryset = Device.objects.all()
#
# Modules
#
@@ -2157,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsolePort)
@@ -2221,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ConsoleServerPort)
@@ -2285,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerPort)
@@ -2349,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(PowerOutlet)
@@ -2413,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(Interface)
@@ -2523,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(FrontPort)
@@ -2587,7 +2617,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(RearPort)
@@ -2651,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(ModuleBay)
@@ -2707,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(DeviceBay)
@@ -2832,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
@register_model_view(InventoryItem)
@@ -3469,6 +3495,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView):
table = tables.PowerPanelTable
@register_model_view(PowerPanel, 'contacts')
class PowerPanelContactsView(ObjectContactsView):
queryset = PowerPanel.objects.all()
#
# Power feeds
#

View File

@@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
'fields': ('ALLOWED_URL_SCHEMES',),
}),
('Banners', {
'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'),
'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'),
'classes': ('monospace',),
}),
('Pagination', {

View File

@@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet):
VISIBILITY_READ_WRITE = 'read-write'
VISIBILITY_READ_ONLY = 'read-only'
VISIBILITY_HIDDEN = 'hidden'
VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset'
CHOICES = (
(VISIBILITY_READ_WRITE, 'Read/Write'),
(VISIBILITY_READ_ONLY, 'Read-only'),
(VISIBILITY_HIDDEN, 'Hidden'),
(VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'),
)
@@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet):
ACTION_DELETE = 'delete'
CHOICES = (
(ACTION_CREATE, 'Create'),
(ACTION_UPDATE, 'Update'),
(ACTION_DELETE, 'Delete'),
(ACTION_CREATE, 'Create', 'green'),
(ACTION_UPDATE, 'Update', 'blue'),
(ACTION_DELETE, 'Delete', 'red'),
)

View File

@@ -65,8 +65,14 @@ class Condition:
"""
Evaluate the provided data to determine whether it matches the condition.
"""
def _get(obj, key):
if isinstance(obj, list):
return [dict.get(i, key) for i in obj]
return dict.get(obj, key)
try:
value = functools.reduce(dict.get, self.attr.split('.'), data)
value = functools.reduce(_get, self.attr.split('.'), data)
except TypeError:
# Invalid key path
value = None

View File

@@ -10,8 +10,9 @@ 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, reverse
from django.urls import NoReverseMatch, resolve, reverse
from django.utils.translation import gettext as _
from extras.utils import FeatureQuery
@@ -35,7 +36,8 @@ def get_content_type_labels():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.filter(
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange')
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') |
Q(app_label='extras', model='configcontext')
).order_by('app_label', 'model')
]
@@ -148,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget):
filters = forms.JSONField(
required=False,
label='Object filters',
help_text=_("Only objects matching the specified filters will be counted")
help_text=_("Filters to apply when counting the number of objects")
)
def clean_filters(self):
@@ -157,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget):
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
for model in get_models_from_content_types(self.cleaned_data.get('models')):
try:
# Validate the filters by creating a QuerySet
model.objects.filter(**data).none()
except Exception:
model_name = model._meta.verbose_name_plural
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data
def render(self, request):
@@ -171,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget):
for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission):
url = reverse(get_viewname(model, 'list'))
qs = model.objects.restrict(request.user, 'view')
# Apply any specified filters
if filters := self.config.get('filters'):
qs = qs.filter(**filters)
params = QueryDict(mutable=True)
params.update(filters)
filterset = getattr(resolve(url).func.view_class, 'filterset', None)
qs = filterset(params, qs).qs
url = f'{url}?{params.urlencode()}'
object_count = qs.count
counts.append((model, object_count))
counts.append((model, object_count, url))
else:
counts.append((model, None))
counts.append((model, None, None))
return render_to_string(self.template_name, {
'counts': counts,

View File

@@ -7,12 +7,14 @@ class Empty(Lookup):
Filter on whether a string is empty.
"""
lookup_name = 'empty'
prepare_rhs = False
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
def as_sql(self, compiler, connection):
sql, params = compiler.compile(self.lhs)
if self.rhs:
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
else:
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class NetContainsOrEquals(Lookup):

View File

@@ -13,6 +13,22 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='customfield',
name='name',
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
field=models.CharField(
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
message='Only alphanumeric characters and underscores are allowed.',
regex='^[a-z0-9_]+$',
),
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
inverse_match=True,
message='Double underscores are not permitted in custom field names.',
regex=r'__',
),
],
),
),
]

View File

@@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
message="Only alphanumeric characters and underscores are allowed.",
flags=re.IGNORECASE
),
RegexValidator(
regex=r'__',
message="Double underscores are not permitted in custom field names.",
flags=re.IGNORECASE,
inverse_match=True
),
)
)
label = models.CharField(

View File

@@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
:param context: The context passed to Jinja2
"""
text = render_jinja2(self.link_text, context)
text = render_jinja2(self.link_text, context).strip()
if not text:
return {}
link = render_jinja2(self.link_url, context)
link = render_jinja2(self.link_url, context).strip()
link_target = ' target="_blank"' if self.new_window else ''
# Sanitize link text
@@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
text = clean_html(text, allowed_schemes)
# Sanitize link
link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#')
link = urllib.parse.quote(link, safe='/:?&=%+[]@#')
# Verify link scheme is allowed
result = urllib.parse.urlparse(link)

View File

@@ -112,3 +112,6 @@ class StagedChange(ChangeLoggedModel):
instance = self.model.objects.get(pk=self.object_id)
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
instance.delete()
def get_action_color(self):
return ChangeActionChoices.colors.get(self.action)

View File

@@ -22,6 +22,14 @@ __all__ = (
'WebhookTable',
)
IMAGEATTACHMENT_IMAGE = '''
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
{% endif %}
'''
class CustomFieldTable(NetBoxTable):
name = tables.Column(
@@ -73,6 +81,7 @@ class ExportTemplateTable(NetBoxTable):
linkify=True
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
@@ -95,6 +104,9 @@ class ImageAttachmentTable(NetBoxTable):
parent = tables.Column(
linkify=True
)
image = tables.TemplateColumn(
template_code=IMAGEATTACHMENT_IMAGE,
)
size = tables.Column(
orderable=False,
verbose_name='Size (bytes)'
@@ -218,6 +230,7 @@ class ConfigContextTable(NetBoxTable):
verbose_name='Active'
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
@@ -242,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable):
linkify=True
)
is_synced = columns.BooleanColumn(
orderable=False,
verbose_name='Synced'
)
tags = columns.TagColumn(

View File

@@ -29,6 +29,17 @@ class CustomFieldTest(TestCase):
cls.object_type = ContentType.objects.get_for_model(Site)
def test_invalid_name(self):
"""
Try creating a CustomField with an invalid name.
"""
with self.assertRaises(ValidationError):
# Invalid character
CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
with self.assertRaises(ValidationError):
# Double underscores not permitted
CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean()
def test_text_field(self):
value = 'Foobar!'

View File

@@ -9,6 +9,7 @@ from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.utils import serialize_object
from .choices import *
from .models import Webhook
@@ -116,5 +117,6 @@ def flush_webhooks(queue):
snapshots=data['snapshots'],
timestamp=str(timezone.now()),
username=data['username'],
request_id=data['request_id']
request_id=data['request_id'],
retry=get_rq_retry()
)

View File

@@ -1,6 +1,7 @@
from django import forms
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 dcim.models import Device, Interface, Site
@@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm):
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
if not data:
return
# Limit VLAN queryset by assigned site and/or group (if specified)
params = {}
if data.get('site'):
params[f"site__{self.fields['site'].to_field_name}"] = data.get('site')
if data.get('vlan_group'):
params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group')
if params:
self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params)
site = data.get('site')
vlan_group = data.get('vlan_group')
# Limit VLAN queryset by assigned site and/or group (if specified)
query = Q()
if site:
query |= Q(**{
f"site__{self.fields['site'].to_field_name}": site
})
# Don't Forget to include VLANs without a site in the filter
query |= Q(**{
f"site__{self.fields['site'].to_field_name}__isnull": True
})
if vlan_group:
query &= Q(**{
f"group__{self.fields['vlan_group'].to_field_name}": vlan_group
})
queryset = self.fields['vlan'].queryset.filter(query)
self.fields['vlan'].queryset = queryset
class IPRangeImportForm(NetBoxModelImportForm):

View File

@@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
selector=True,
label=_('VLAN'),
query_params={
'site_id': '$site',
}
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
@@ -328,6 +326,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
):
self.initial['primary_for_parent'] = True
# Disable object assignment fields if the IP address is designated as primary
if self.initial.get('primary_for_parent'):
self.fields['interface'].disabled = True
self.fields['vminterface'].disabled = True
self.fields['fhrpgroup'].disabled = True
def clean(self):
super().clean()
@@ -340,7 +344,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
selected_objects[1]: "An IP address can only be assigned to a single object."
})
elif selected_objects:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
assigned_object = self.cleaned_data[selected_objects[0]]
if 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"
)
self.instance.assigned_object = assigned_object
else:
self.instance.assigned_object = None
@@ -351,6 +360,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
# Do not allow assigning a network ID or broadcast address to an interface.
if interface and (address := self.cleaned_data.get('address')):
if address.ip == address.network:
msg = f"{address} is a network ID, which may not be assigned to an interface."
if address.version == 4 and address.prefixlen not in (31, 32):
raise ValidationError(msg)
if address.version == 6 and address.prefixlen not in (127, 128):
raise ValidationError(msg)
if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
msg = f"{address} is a broadcast address, which may not be assigned to an interface."
raise ValidationError(msg)
def save(self, *args, **kwargs):
ipaddress = super().save(*args, **kwargs)

View File

@@ -783,6 +783,14 @@ class IPAddress(PrimaryModel):
if available_ips:
return next(iter(available_ips))
def get_related_ips(self):
"""
Return all IPAddresses belonging to the same VRF.
"""
return IPAddress.objects.exclude(address=str(self.address)).filter(
vrf=self.vrf, address__net_contained_or_equal=str(self.address)
)
def clean(self):
super().clean()

View File

@@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
self.assertHttpStatus(self.client.get(url), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import(self):
"""
Custom import test for YAML-based imports (versus CSV)
"""
IMPORT_DATA = """
prefix: 10.1.1.0/24
status: active
vlan: 101
site: Site 1
"""
# Note, a site is not tied to the VLAN to verify the fix for #12622
VLAN.objects.create(vid=101, name='VLAN101')
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.1.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.site.name, "Site 1")
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
This test covers a unique import edge case where VLAN group is specified during the import.
"""
IMPORT_DATA = """
prefix: 10.1.2.0/24
status: active
vlan: 102
site: Site 1
vlan_group: Group 1
"""
vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1"))
VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group)
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.2.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.vlan.vid, 102)
self.assertEqual(prefix.site.name, "Site 1")
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange

View File

@@ -9,6 +9,7 @@ from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
from utilities.utils import count_related
from utilities.views import ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
@@ -755,19 +756,9 @@ class IPAddressView(generic.ObjectView):
# Limit to a maximum of 10 duplicates displayed here
duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
address=str(instance.address)
).filter(
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
)
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
related_ips_table.configure(request)
return {
'parent_prefixes_table': parent_prefixes_table,
'duplicate_ips_table': duplicate_ips_table,
'related_ips_table': related_ips_table,
}
@@ -872,6 +863,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView):
table = tables.IPAddressTable
@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses')
class IPAddressRelatedIPsView(generic.ObjectChildrenView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/ipaddress/ip_addresses.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
weight=500,
hide_if_empty=True,
)
def get_children(self, request, parent):
return parent.get_related_ips().restrict(request.user, 'view')
#
# VLAN groups
#
@@ -1292,6 +1301,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView):
table = tables.L2VPNTable
@register_model_view(L2VPN, 'contacts')
class L2VPNContactsView(ObjectContactsView):
queryset = L2VPN.objects.all()
#
# L2VPN terminations
#

View File

@@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model
__all__ = (
'BriefModeMixin',
'BulkDestroyModelMixin',
'BulkUpdateModelMixin',
'CustomFieldsMixin',
'ExportTemplatesMixin',
'BulkDestroyModelMixin',
'ObjectValidationMixin',
'SequentialBulkCreatesMixin',
)
@@ -94,6 +95,30 @@ class ExportTemplatesMixin:
return super().list(request, *args, **kwargs)
class SequentialBulkCreatesMixin:
"""
Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
appropriately.
"""
@transaction.atomic
def create(self, request, *args, **kwargs):
if not isinstance(request.data, list):
# Creating a single object
return super().create(request, *args, **kwargs)
return_data = []
for data in request.data:
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
return_data.append(serializer.data)
headers = self.get_success_headers(serializer.data)
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
class BulkUpdateModelMixin:
"""
Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one

View File

@@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend):
try:
group_list.append(Group.objects.get(name=name))
except Group.DoesNotExist:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS:
group_list.append(Group.objects.create(name=name))
else:
logging.error(
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
if group_list:
user.groups.set(group_list)
logger.debug(

View File

@@ -28,6 +28,17 @@ PARAMS = (
),
},
),
ConfigParam(
name='BANNER_MAINTENANCE',
label=_('Maintenance banner'),
default='NetBox is currently in maintenance mode. Functionality may be limited.',
description=_('Additional content to display when in maintenance mode'),
field_kwargs={
'widget': forms.Textarea(
attrs={'class': 'vLargeTextField'}
),
},
),
ConfigParam(
name='BANNER_TOP',
label=_('Top banner'),

View File

@@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet):
# create the new filter with the same type because there is no guarantee the defined type
# is the same as the default type for the field
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
new_filter = type(existing_filter)(
filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
new_filter = filter_cls(
field_name=field_name,
lookup_expr=lookup_expr,
label=existing_filter.label,
@@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet):
return filters
@classmethod
def filter_for_lookup(cls, field, lookup_type):
if lookup_type == 'empty':
return django_filters.BooleanFilter, {}
return super().filter_for_lookup(field, lookup_type)
class ChangeLoggedModelFilterSet(BaseFilterSet):
"""

View File

@@ -3,19 +3,21 @@ import uuid
from urllib import parse
from django.conf import settings
from django.contrib import auth
from django.contrib import auth, messages
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
from django.core.exceptions import ImproperlyConfigured
from django.db import ProgrammingError
from django.db import connection, ProgrammingError
from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect
from extras.context_managers import change_logging
from netbox.config import clear_config
from netbox.config import clear_config, get_config
from netbox.views import handler_500
from utilities.api import is_api_request, rest_api_server_error
__all__ = (
'CoreMiddleware',
'MaintenanceModeMiddleware',
'RemoteUserMiddleware',
)
@@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_):
groups = []
logger.debug(f"Groups are {groups}")
return groups
class MaintenanceModeMiddleware:
"""
Middleware that checks if the application is in maintenance mode
and restricts write-related operations to the database.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if get_config().MAINTENANCE_MODE:
self._set_session_type(
allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
)
return self.get_response(request)
@staticmethod
def _set_session_type(allow_write):
"""
Prevent any write-related database operations.
Args:
allow_write (bool): If True, write operations will be permitted.
"""
with connection.cursor() as cursor:
mode = 'READ WRITE' if allow_write else 'READ ONLY'
cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};')
def process_exception(self, request, exception):
"""
Prevent any write-related database operations if an exception is raised.
"""
if isinstance(exception, InternalError):
error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
'operations. Please try again later.'
if is_api_request(request):
return rest_api_server_error(request, error=error_message)
messages.error(request, error_message)
return HttpResponseRedirect(request.path_info)

View File

@@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model):
data = {}
for field in CustomField.objects.get_for_model(self):
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
value = self.custom_field_data.get(field.name)
# Skip fields that are hidden if 'omit_hidden' is set
if omit_hidden:
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN:
continue
if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value:
continue
data[field] = field.deserialize(value)
return data
@@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model):
for cf in visible_custom_fields:
value = self.custom_field_data.get(cf.name)
if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET:
continue
value = cf.deserialize(value)
groups[cf.group_name][cf] = value

View File

@@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
label=_('Connections'),
items=(
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']),
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
MenuItem(
link='dcim:interface_connections_list',
link_text=_('Interface Connections'),
@@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu(
MenuItem(
link='extras:report_list',
link_text=_('Reports'),
permissions=['extras.view_report']
permissions=['extras.view_report'],
buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
),
MenuItem(
link='extras:script_list',
link_text=_('Scripts'),
permissions=['extras.view_script']
permissions=['extras.view_script'],
buttons=get_model_buttons('extras', "scriptmodule", actions=['add'])
),
),
),

View File

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.5.1'
VERSION = '3.5.4'
# Hostname
HOSTNAME = platform.node()
@@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {})
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
@@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0)
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend')
SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False)
@@ -382,6 +385,7 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
@@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = (
f'/{BASE_PATH}metrics',
)
# All URLs starting with a string listed here are exempt from maintenance mode enforcement
MAINTENANCE_EXEMPT_PATHS = (
f'/{BASE_PATH}admin/',
)
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}

View File

@@ -234,8 +234,12 @@ class ActionsColumn(tables.Column):
return ''
model = table.Meta.model
request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
if request := getattr(table, 'context', {}).get('request'):
return_url = request.GET.get('return_url', request.get_full_path())
url_appendix = f'?return_url={quote(return_url)}'
else:
url_appendix = ''
html = ''
# Compile actions menu

View File

@@ -140,10 +140,14 @@ class BaseTable(tables.Table):
if request.user.is_authenticated:
table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True)
else:
# If the ordering has been set to none (empty), clear any existing preference.
request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering

View File

@@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase):
list(new_user.groups.all())
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
REMOTE_AUTH_GROUP_SYNC_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_GROUPS=True,
LOGIN_REQUIRED=True,
)
def test_remote_auth_remote_groups_autocreate(self):
"""
Test enabling remote authentication with group sync and autocreate
enabled with the default configuration.
"""
headers = {
"HTTP_REMOTE_USER": "remoteuser2",
"HTTP_REMOTE_USER_GROUP": "Group 1|Group 2",
}
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS)
self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED)
self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER")
self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP")
self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|")
groups = (
Group(name="Group 1"),
Group(name="Group 2"),
)
response = self.client.get(reverse("home"), follow=True, **headers)
self.assertEqual(response.status_code, 200)
new_user = User.objects.get(username="remoteuser2")
self.assertEqual(
int(self.client.session.get("_auth_user_id")),
new_user.pk,
msg="Authentication failed",
)
self.assertListEqual(
[group.name for group in groups],
[group.name for group in list(new_user.groups.all())],
)
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -30,6 +30,7 @@
"dayjs": "^1.11.5",
"flatpickr": "4.6.13",
"gridstack": "^7.2.3",
"html-entities": "^2.3.3",
"htmx.org": "^1.8.0",
"just-debounce-it": "^3.1.1",
"query-string": "^7.1.1",

View File

@@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util';
import { initButtons } from './buttons';
import { initSelect } from './select';
import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs';
function initDepedencies(): void {
for (const init of [initButtons, initSelect, initObjectSelector]) {
for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
init();
}
}
@@ -22,4 +23,8 @@ export function initHtmx(): void {
}
}
}
for (const element of getElements('[hx-trigger=load]')) {
element.addEventListener('htmx:afterSettle', initDepedencies);
}
}

View File

@@ -1,5 +1,6 @@
import { readableColor } from 'color2k';
import debounce from 'just-debounce-it';
import { encode } from 'html-entities';
import queryString from 'query-string';
import SlimSelect from 'slim-select';
import { createToast } from '../../bs';
@@ -446,7 +447,7 @@ export class APISelect {
// Build SlimSelect options from all already-selected options.
const preSelectedOptions = preSelected.map(option => ({
value: option.value,
text: option.innerText,
text: encode(option.innerText),
selected: true,
disabled: false,
})) as Option[];
@@ -454,7 +455,7 @@ export class APISelect {
let options = [] as Option[];
for (const result of data.results) {
let text = result.display;
let text = encode(result.display);
if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text.

View File

@@ -1818,6 +1818,11 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
html-entities@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
htmx.org@^1.8.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3"

View File

@@ -77,10 +77,10 @@ Blocks:
</div>
{% endif %}
{% if config.MAINTENANCE_MODE %}
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
NetBox is currently in maintenance mode. Functionality may be limited.
{{ config.BANNER_MAINTENANCE|escape }}
</div>
{% endif %}

View File

@@ -70,7 +70,6 @@
<div class="col col-md-6">
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>

View File

@@ -132,9 +132,16 @@
</tr>
{% for field, value in fields.items %}
<tr>
<td>
<span title="{{ field.description|escape }}">{{ field }}</span>
</td>
<th scope="row">{{ field }}
{% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td>
{% customfield_value field value %}
</td>

View File

@@ -43,7 +43,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -38,7 +38,6 @@
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
<div class="col col-md-12">

View File

@@ -298,8 +298,30 @@
</div>
{% endif %}
</div>
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">Dimensions</h5>
<div class="card-body table-responsive">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Height</th>
<td>
{{ object.device_type.u_height }}U
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<td>
{% if object.total_weight %}
{{ object.total_weight|floatformat }} Kilograms
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% if object.rack and object.position %}
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">

View File

@@ -28,8 +28,22 @@
</div>
<div class="col-7">
<div class="card">
<h5 class="card-header">Context Data</h5>
<pre class="card-body">{{ context_data|pprint }}</pre>
<div class="accordion accordion-flush" id="renderConfig">
<div class="card-body">
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
Context Data
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
<div class="accordion-body">
<pre class="card-body">{{ context_data|pprint }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -123,11 +123,11 @@
<table class="table table-hover">
<tr>
<th scope="row">MAC Address</th>
<td><span class="text-monospace">{{ object.mac_address|placeholder }}</span></td>
<td><span class="font-monospace">{{ object.mac_address|placeholder }}</span></td>
</tr>
<tr>
<th scope="row">WWN</th>
<td><span class="text-monospace">{{ object.wwn|placeholder }}</span></td>
<td><span class="font-monospace">{{ object.wwn|placeholder }}</span></td>
</tr>
<tr>
<th scope="row">VRF</th>

View File

@@ -65,7 +65,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}

View File

@@ -51,7 +51,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -40,7 +40,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %}
{% plugin_right_page object %}
</div>

View File

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

View File

@@ -46,7 +46,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

View File

@@ -87,11 +87,13 @@
<th scope="row">Physical Address</th>
<td class="position-relative">
{% if object.physical_address %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map
</a>
</div>
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map
</a>
</div>
{% endif %}
<span>{{ object.physical_address|linebreaksbr }}</span>
{% else %}
{{ ''|placeholder }}
@@ -106,11 +108,13 @@
<th scope="row">GPS Coordinates</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>
</div>
{% endif %}
<span>{{ object.latitude }}, {{ object.longitude }}</span>
{% else %}
{{ ''|placeholder }}
@@ -127,7 +131,6 @@
</div>
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %}
{% include 'inc/panels/contacts.html' %}
<div class="card">
<h5 class="card-header">Locations</h5>
<div class='card-body'>

View File

@@ -42,7 +42,6 @@
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">

View File

@@ -1,10 +1,8 @@
{% load helpers %}
{% if counts %}
<div class="list-group list-group-flush">
{% for model, count in counts %}
{% for model, count, url in counts %}
{% if count != None %}
<a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action">
<a href="{{ url }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between align-items-center">
{{ model|meta:"verbose_name_plural"|bettertitle }}
<h6 class="mb-1">{{ count }}</h6>

View File

@@ -1,63 +0,0 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Contacts</h5>
<div class="card-body">
{% with contacts=object.contacts.all %}
{% if contacts.exists %}
<table class="table table-hover">
<tr>
<th>Name</th>
<th>Role</th>
<th>Priority</th>
<th>Phone</th>
<th>Email</th>
<th></th>
</tr>
{% for contact in contacts %}
<tr>
<td>{{ contact.contact|linkify }}</td>
<td>{{ contact.role|placeholder }}</td>
<td>{{ contact.get_priority_display|placeholder }}</td>
<td>
{% if contact.contact.phone %}
<a href="tel:{{ contact.contact.phone }}">{{ contact.contact.phone }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td>
{% if contact.contact.email %}
<a href="mailto:{{ contact.contact.email }}">{{ contact.contact.email }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
<td class="text-end noprint">
{% if perms.tenancy.change_contactassignment %}
<a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.tenancy.delete_contactassignment %}
<a href="{% url 'tenancy:contactassignment_delete' pk=contact.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
{% endwith %}
</div>
{% if perms.tenancy.add_contactassignment %}
<div class="card-footer text-end noprint">
<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">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
</div>
{% endif %}
</div>

View File

@@ -12,8 +12,15 @@
<table class="table table-hover attr-table">
{% for field, value in fields.items %}
<tr>
<th scope="row">
<span title="{{ field.description|escape }}">{{ field }}</span>
<th scope="row">{{ field }}
{% if field.description %}
<i
class="mdi mdi-information text-primary"
data-bs-toggle="tooltip"
data-bs-placement="right"
title="{{ field.description|escape }}"
></i>
{% endif %}
</th>
<td>
{% customfield_value field value %}

View File

@@ -1,12 +1,8 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">
Images
</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
hx-trigger="load"></div>
<h5 class="card-header">Images</h5>
{% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %}
{% if perms.extras.add_imageattachment %}
<div class="card-footer text-end noprint">
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">

View File

@@ -3,13 +3,6 @@
{% load plugins %}
{% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-4">
@@ -116,7 +109,6 @@
{% if duplicate_ips_table.rows %}
{% include 'inc/panel_table.html' with table=duplicate_ips_table heading='Duplicate IPs' panel_class='danger' %}
{% endif %}
{% include 'inc/panel_table.html' with table=related_ips_table heading='Related IPs' %}
<div class="card">
<h5 class="card-header">Services</h5>
<div class="card-body htmx-container table-responsive"

View File

@@ -0,0 +1,8 @@
{% extends 'generic/object.html' %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.vrf %}
<li class="breadcrumb-item"><a href="{% url 'ipam:ipaddress_list' %}?vrf_id={{ object.vrf.pk }}">{{ object.vrf }}</a></li>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends 'ipam/ipaddress/base.html' %}
{% load helpers %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_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>
</form>
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -37,7 +37,6 @@
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}

View File

@@ -0,0 +1,27 @@
{% extends base_template %}
{% 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">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
</a>
{% endif %}
{% endblock %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="ContactAssignmentTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@@ -30,7 +30,6 @@
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-5">

View File

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

View File

@@ -37,7 +37,6 @@
<div class="col col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/contacts.html' %}
{% plugin_right_page object %}
</div>
</div>

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