Compare commits

...

225 Commits

Author SHA1 Message Date
Jeremy Stretch
85c60670dc Merge pull request #11059 from netbox-community/develop
Release v3.3.9
2022-11-30 16:14:00 -05:00
jeremystretch
f2f36c67f6 Release v3.3.9 2022-11-30 15:51:37 -05:00
jeremystretch
281934cf34 Fixes #11047: Cloning a rack reservation should replicate rack & user 2022-11-30 15:37:50 -05:00
jeremystretch
00d72f18cf Annotate need for natural ordering 2022-11-30 15:33:01 -05:00
Arthur
b36afdc924 11014 code review changes 2022-11-30 15:33:01 -05:00
Arthur
4ed45e4031 11014 fix rack elevation name sorting 2022-11-30 15:33:01 -05:00
Arthur
cf0258204f 11048 fix connected_endpoint refs 2022-11-30 15:13:45 -05:00
Patrick Hurrelmann
3bd560add8 Fixes #11028 - Enable clearing of the color field for front and rear ports in bulk edit 2022-11-30 15:09:07 -05:00
Arthur
9e51a8d9d2 10999 fix power utilization on Device detail 2022-11-29 09:38:04 -05:00
Arthur
f59c6699f6 11025 fix error tag toast 2022-11-29 09:36:48 -05:00
jeremystretch
80f5eeacdd Fix issues loading demo data 2022-11-29 09:18:03 -05:00
jeremystretch
b1da374df2 Fixes #10997: Fix exception when editing NAT IP for VM with no cluster 2022-11-22 08:52:21 -05:00
jeremystretch
dc1da0a738 Fixes #10996: Hide checkboxes on child object lists when no bulk operations are available 2022-11-22 08:52:04 -05:00
jeremystretch
4623858849 Fixes #10936: Permit demotion of device/VM primary IP via IP address edit form 2022-11-21 15:36:13 -05:00
jeremystretch
9c5891f1b6 Fixes #10929: Raise validation error when attempting to create a duplicate cable termination 2022-11-21 14:08:33 -05:00
jeremystretch
d5538c1ca3 Fixes #10241: Support referencing custom field related objects by attribute in addition to PK 2022-11-21 12:48:13 -05:00
jeremystretch
90f15b8d55 Fixes #10938: render_field template tag should respect label kwarg 2022-11-21 09:49:30 -05:00
jeremystretch
4e27e8d3dd Fixes #10969: Update cable paths ending at associated rear port when creating new front ports 2022-11-21 09:44:08 -05:00
Arthur
3a89a676cd 10869 convert docstring to comment 2022-11-18 13:47:55 -05:00
jeremystretch
0885333b11 Fixes #9223: Fix serialization of array field values in change log 2022-11-18 11:24:14 -05:00
jeremystretch
c287641363 Changelog for #10236, #10653 2022-11-18 11:23:30 -05:00
Arthur Hanson
de9646d096 10653 log failed login attempts on INFO (#10843)
* 10653 log failed login attempts on INFO

* 10653 use signal to log failed login attempts

* 10653 use signal to log failed login attempts

* Update netbox/users/signals.py

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

* Update netbox/users/apps.py

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

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-11-18 08:57:57 -05:00
Arthur Hanson
dd2520d675 10236 fix device detail for power-feed (#10961)
* 10236 fix device detail for power-feed

* 10236 optimize with statement
2022-11-18 08:55:28 -05:00
jeremystretch
3a5914827b Fixes #6389: Call snapshot() on object when processing deletions 2022-11-17 21:04:55 -05:00
jeremystretch
cf55e96241 Fixes #10721: Disable ordering by custom object field columns 2022-11-17 16:30:54 -05:00
jeremystretch
bd29d15814 Fixes #10579: Mark cable traces terminating to a provider network as complete 2022-11-17 16:08:29 -05:00
jeremystretch
d3911e2a4c Fixes #9878: Fix spurious error message when rendering REST API docs 2022-11-17 15:13:37 -05:00
jeremystretch
eb591731ef #10712: Remove pin for swagger-spec-validator (fixed in v3.0.3) 2022-11-17 13:06:51 -05:00
jeremystretch
e40e2550a6 PRVB 2022-11-16 11:34:45 -05:00
Jeremy Stretch
bfda5d9011 Merge pull request #10937 from netbox-community/develop
Release v3.3.8
2022-11-16 11:32:44 -05:00
jeremystretch
62a80c46a8 Release v3.3.8 2022-11-16 10:45:29 -05:00
jeremystretch
ceec1055e0 Changelog for #10356, #10904, #10920 2022-11-16 10:40:18 -05:00
jeremystretch
540bba4544 Closes #10920: Include request cookies when queuing a custom script 2022-11-16 10:37:06 -05:00
jeremystretch
44c248e6c2 Closes #10934: Update release package URL 2022-11-16 10:36:30 -05:00
Patrick Hurrelmann
3a62fd49e6 Fixes: #10356 backplane connections (#10554)
* Fixes: #10356 Add interface type and cable for backplane connections

* Allow Backplone for front and readports , too.

* Correct tyo in port definition

* pep8 fix (blank lines)

* Remove port type and changed name/description of backplane cable

* Omit backplane cable type

Co-authored-by: Patrick Hurrelmann <patrick.hurrelmann@nfon.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 10:26:46 -05:00
Derick Vigne
a2007a4728 Closes #10904: Added Colors to SVG for Front and Rear Ports (#10905)
* Added Colors to SVG for Front and Reaer Ports

Fix for feature request 10904 thanks to @TheZackCodec

* Simplify termination color resolution

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 09:57:49 -05:00
jeremystretch
316c3808f7 Changelog for #9439, #10902, #10914, #10915, #10919 2022-11-16 09:43:11 -05:00
Arthur Hanson
928d880f0e 10902 add location to power feed form (#10913)
* 10902 add location to power feed form

* Bind location field to selected site

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-11-16 09:27:49 -05:00
Arthur
c6930e3ea8 10919 add location to cable termination panels 2022-11-16 09:22:39 -05:00
Arthur
564884a774 10903 add module type on manufacturer page 2022-11-14 14:17:06 -05:00
Arthur
7401fd7050 10909 add l2vpn to tenant stats 2022-11-14 14:16:18 -05:00
jeremystretch
4a95cfd1c4 Permanently connect change logging & webhook receivers 2022-11-14 09:31:25 -05:00
jeremystretch
cd8943144b Use context vars instead of thread-local storage for change logging 2022-11-14 09:31:25 -05:00
jeremystretch
8400509358 Fixes #10891: Populate tag selection list for service filter form 2022-11-14 09:28:03 -05:00
jeremystretch
d971131198 Fixes #10897: Fix form widget styling on FHRP group form 2022-11-14 09:24:12 -05:00
jeremystretch
5729a06348 Fixes #10910: Fix cable creation links on power port view 2022-11-14 09:20:02 -05:00
jeremystretch
d59d23e308 Fixes #10881: Fix dark mode coloring for data on device status page 2022-11-10 16:47:17 -05:00
jeremystretch
3d1501e8fd Changelog for #10837, #10874 2022-11-10 16:33:34 -05:00
Brian Candler
c854c29016 Fix broken cookie paths when BASE_PATH is set (introduced in #10706) (#10856)
Fixes #10837
2022-11-10 16:23:05 -05:00
Arthur Hanson
33d8f8e5e7 10874 remove link to contact roles (#10879) 2022-11-10 16:19:43 -05:00
jeremystretch
93e241e8f3 Changelog for #10709, #10829 2022-11-04 16:56:52 -04:00
Arthur
43da786016 10829 fix top edit selected button 2022-11-04 16:54:24 -04:00
Arthur
271d524687 10709 add AzureAD Tenant Oauth2 2022-11-04 09:12:22 -04:00
Arthur Hanson
4ebcdd2b8f 8072 move js code from template to static file (#10824) 2022-11-03 09:29:45 -04:00
jeremystretch
2af8891f70 PRVB 2022-11-01 17:11:55 -04:00
jeremystretch
4e39021b6f Merge branch 'master' into develop 2022-11-01 17:10:18 -04:00
jeremystretch
2cd5fce62d Release v3.3.7 2022-11-01 17:09:55 -04:00
jeremystretch
ade307bc03 Fixes #10809: Permit nullifying site time_zone via REST API 2022-11-01 17:09:55 -04:00
jeremystretch
c8be4ef8e2 Fixes #10791: Permit nullifying VLAN group scope_type via REST API 2022-11-01 17:09:55 -04:00
jeremystretch
816214361d Fixes #10803: Fix exception when ordering contacts by number of assignments 2022-11-01 17:09:55 -04:00
jeremystretch
d1970ca85b Changelog for #10282, #10770 2022-11-01 17:09:55 -04:00
Arthur
8001694a4c 10282 fix race condition in API IP creation 2022-11-01 17:09:55 -04:00
Arthur
10e258739f 10770 fix social auth 2022-11-01 17:09:55 -04:00
jeremystretch
f3fdf03661 Changelog for #10666 (missed in v3.3.6) 2022-11-01 17:09:55 -04:00
jeremystretch
44814f759c PRVB 2022-11-01 17:09:55 -04:00
jeremystretch
4f5caa5ed2 Release v3.3.7 2022-11-01 16:48:40 -04:00
jeremystretch
aa7f04bf1b Fixes #10809: Permit nullifying site time_zone via REST API 2022-11-01 16:45:32 -04:00
jeremystretch
aaf1ea52b7 Fixes #10791: Permit nullifying VLAN group scope_type via REST API 2022-11-01 15:38:10 -04:00
jeremystretch
7990cfb078 Fixes #10803: Fix exception when ordering contacts by number of assignments 2022-11-01 15:27:35 -04:00
jeremystretch
a25ee66150 Changelog for #10282, #10770 2022-10-31 15:15:45 -04:00
Arthur
867af61875 10282 fix race condition in API IP creation 2022-10-31 15:04:43 -04:00
Arthur
8f4fa065f9 10770 fix social auth 2022-10-31 15:02:57 -04:00
jeremystretch
edb5220228 Changelog for #10666 (missed in v3.3.6) 2022-10-26 15:11:44 -04:00
jeremystretch
18332bdbf1 PRVB 2022-10-26 10:23:50 -04:00
Jeremy Stretch
f1a7bceef2 Merge pull request #10758 from netbox-community/develop
Release v3.3.6
2022-10-26 10:18:44 -04:00
jeremystretch
eac2ace80b Release v3.3.6 2022-10-26 09:58:31 -04:00
Kevin Petremann
174ba6cf0f Fix LDAP auth: user never updated if inactive 2022-10-26 09:40:05 -04:00
jeremystretch
658c9347f3 Fixes #10682: Correct home view links to connection lists 2022-10-26 09:32:29 -04:00
jeremystretch
7b3ef2ade5 Fixes #10719: Prevent user without sufficient permission from creating an IP address via FHRP group creation 2022-10-26 08:44:20 -04:00
jeremystretch
2a62b628cf Fixes #10723: Distinguish between inside/outside NAT assignments for device/VM primary IPs 2022-10-26 08:23:50 -04:00
Arthur
d8c07abd68 10610 interface_id query on lag return vc interfaces 2022-10-26 08:10:03 -04:00
Arthur Hanson
8d486c5838 10716 add left-right plugins to tags page (#10744)
* 10716 add left-right plugins to tags page

* 10716 add back plugin_full_width
2022-10-26 08:05:15 -04:00
jeremystretch
eb91934d70 Fixes #10745: Correct display of status field in clusters list 2022-10-25 16:41:07 -04:00
jeremystretch
01654765e8 Fixes #10746: Add missing status attribute to cluster view 2022-10-25 16:38:32 -04:00
jeremystretch
4c504870e0 Tweak PR template language 2022-10-21 12:47:19 -04:00
jeremystretch
3d687a6c2d Closes #10718: Optimize object-based permissions enforcement 2022-10-21 12:43:36 -04:00
jeremystretch
96c4696417 Changelog for #9584, #10580, #10639 2022-10-20 16:31:52 -04:00
Arthur Hanson
e7659a5f99 9584 add device type (slug) to filter list (#10630)
* 9584 add device type (slug) to filter list

* 9584 add test
2022-10-20 16:27:51 -04:00
Craig Pund
53c9c3cf8d Fixes #10580 (#10687)
* change IP address accessor to parent object

* set IP assigned check to link to interface

* Fix Assigned not being orderable

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

Co-authored-by: Craig Pund <cpund@iuhealth.org>
Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-10-20 16:26:26 -04:00
Patrick Rauscher
f60312febf Set *_COOKIE_PATH according to BASE_PATH
As discussed in #10639, all three `COOKIE_PATH`s should be set accordingly with the netbox-`BASE_PATH` to improve coexistance with other Django-projects probably hosted on the same Host
2022-10-20 16:24:38 -04:00
jeremystretch
7505baf3a1 Fixes #10712: Fix ModuleNotFoundError exception when generating API schema under Python 3.9+ 2022-10-20 15:49:13 -04:00
jeremystretch
33c6142365 Update documentation section options for new issues 2022-10-19 09:52:10 -04:00
jeremystretch
10e874039f Changelog for #10643, #10646 2022-10-19 09:02:09 -04:00
jeremystretch
060ee2dd96 Revert PR #10621 2022-10-19 08:55:30 -04:00
jeremystretch
43d1182b4b Fix styling for power input, rear port connection links 2022-10-19 08:47:14 -04:00
Arthur
d53da57f63 10646 fix cable power feed filter 2022-10-19 08:42:55 -04:00
Arthur Hanson
028b4b7ea7 10643 add fieldset to device role for improved add/edit form display (#10680)
* 10643 add fieldset to device role for improved add/edit form display

* 10643 update other forms

* 10643 update other forms

* Specify fieldsets for additional models

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-10-19 08:35:23 -04:00
jeremystretch
4cb0230878 Closes #10685: Position A/Z termination cards above the fold under circuit view 2022-10-18 16:51:37 -04:00
Arthur Hanson
2fe8df3cbb 10655 fix contacts display in list views (#10681)
* 10655 fix contacts display in list views

* 10655 review changes
2022-10-18 16:47:14 -04:00
Arthur
64d67e3b00 10584 add clone fields to ipam-service 2022-10-18 16:06:31 -04:00
jeremystretch
aaf829898b Changelog for #10575, #10596 2022-10-12 08:41:41 -04:00
Arthur
8481cf66e3 10575 add requirements for openid connect packages 2022-10-12 08:39:14 -04:00
Arthur Hanson
bb150379a2 10571 replace deprecated mkdoc settings (#10622)
* 10571 replace deprecated mkdoc settings

* Omit landing page from docs nav menu

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-10-12 08:36:02 -04:00
Arthur Hanson
cc811e5a56 10596 add overflow-auto to card-body (#10621)
* 10596 add overflow-auto to card-body

* 10596 add overflow-auto to card-body
2022-10-12 08:31:02 -04:00
jeremystretch
a9e583a693 Changelog for #9669, #9722 2022-10-05 12:04:47 -04:00
Jeremy Stretch
3a3ff474cb Merge pull request #10567 from scanplus/ldap-ca-cert
Added LDAP_CA_CERT_* to LDAP settings
2022-10-05 12:01:39 -04:00
Arthur Hanson
cc00789d35 9669 sanitize social auth usernames (#10549) 2022-10-05 11:50:47 -04:00
Jeremy Stretch
689f11a573 Merge pull request #10555 from kkthxbye-code/10527-update-js-dependencies
Fixes #10527 - Update JS dependencies
2022-10-05 11:04:37 -04:00
jeremystretch
ae90ad1fb7 PRVB 2022-10-05 10:13:02 -04:00
Jeremy Stretch
56d9725c39 Merge pull request #10570 from netbox-community/develop
Release v3.3.5
2022-10-05 10:10:44 -04:00
jeremystretch
1c69bfaf2c Release v3.3.5 2022-10-05 09:47:55 -04:00
Tobias Genannt
5e37f82b2f Added LDAP_CA_CERT_* to LDAP settings
These options can be used to specify a CA certificate to validate the LDAP
server certificate
2022-10-05 14:28:30 +02:00
jeremystretch
bdefd8ea8c Fixes #10562: Correct URL for contacts table tags column 2022-10-05 08:13:33 -04:00
kkthxbye-code
eabd405845 Fix graphiql by pinning esbuild 2022-10-04 22:00:32 +02:00
jeremystretch
03946f2ca8 Fixes #10559: Permit the pinning of a VM to a particular device within a cluster which has no site assignment 2022-10-04 15:46:55 -04:00
jeremystretch
fec8d1bc2f Fixes #10423: Enforce object type validation when creating journal entries 2022-10-04 15:26:52 -04:00
jeremystretch
53f5f46037 #10460: Fix PowerFeed details 2022-10-04 14:36:14 -04:00
kkthxbye-code
b227757b9a Update JS dependencies WIP 2022-10-04 15:02:37 +02:00
jeremystretch
eef5cefb5d Fixes #10460: Restore missing connection details for device components 2022-10-03 16:11:24 -04:00
jeremystretch
7712b81ab9 Fixes #10517: Automatically inherit site assignment from cluster when creating a virtual machine 2022-10-03 15:35:45 -04:00
jeremystretch
7feb86fe55 Changelog for #10352 2022-10-03 15:03:28 -04:00
PieterL75
d1efbf6620 Issue10352 removegetvariables (#10475)
* Add javascript to disable empty form fields

* add js cleanGetUrl

* use addEventListener submit

* use addEventListener

* update collectstatics

* Use FormData to remove empty fields

* optimeze ts-ignore

* update ts-ignore comment

* oneline of ts-ignore

* one line of ts-ingnore

* fix tsc errors by adding types (as per kkthxbye)

Co-authored-by: Pieter Lambrecht <pieter.lambrecht@sentia.com>
2022-10-03 14:32:01 -04:00
jeremystretch
aabee05a6a Changelog for #8424, #10491 2022-10-03 13:58:04 -04:00
jeremystretch
cf062b5b6a Closes #10346: Document how to access plugin config parameters 2022-10-03 13:56:46 -04:00
Arthur Hanson
0b6a3898fe 8424 device location (#10544)
* 8424 fix merge

* 8424 fix merge

* 8424 fix merge

* 8424 fix merge
2022-10-03 13:55:05 -04:00
Jeremy Stretch
517ebcfbcd Merge pull request #10525 from netbox-community/10491-delete-dependant
10491 improve error message for ProtectedError on contact assignment
2022-10-03 13:27:34 -04:00
jeremystretch
9ef24d3f43 Fixes #10513: Disable the reassignment of a module to a new device 2022-10-03 11:11:51 -04:00
Arthur
02ffc2ddee 10491 improve error message for ProtectedError on contact assignment 2022-09-30 09:09:21 -07:00
jeremystretch
62820ea2b8 Add workflow_dispatch event 2022-09-29 12:36:10 -04:00
jeremystretch
04738587e8 Move permissions block to root 2022-09-29 12:17:10 -04:00
jeremystretch
cbbfcd0e7b Bump stale to v6 2022-09-29 12:00:44 -04:00
jeremystretch
309a70df89 Tweak workflow permissions 2022-09-29 11:59:15 -04:00
Alex
4cb6984a65 GitHub Workflows security hardening (#10456)
* build: harden lock.yml permissions

Signed-off-by: Alex <aleksandrosansan@gmail.com>

* build: harden stale.yml permissions

Signed-off-by: Alex <aleksandrosansan@gmail.com>

* build: harden ci.yml permissions

Signed-off-by: Alex <aleksandrosansan@gmail.com>

Signed-off-by: Alex <aleksandrosansan@gmail.com>
2022-09-29 11:41:33 -04:00
jeremystretch
3c32c09a5a Fixes #10496: Use page.canonical_url to identify ReadTheDocs builds 2022-09-28 09:30:38 -04:00
jeremystretch
2d9852d6f1 Fixes #10408: Fix validation when attempting to add redundant contact assignments 2022-09-27 13:11:57 -04:00
jeremystretch
05542324fc Changelog for #10465, #10480 2022-09-27 11:53:11 -04:00
Patrick Hurrelmann
669e86f96e Fixes: #10465 Format all remaining displayed rackunits with floatformat (#10481)
* Fixes: #10465 Try to finish #10268 and format all remaining displayed rackunits with floatformat

* #10465: PEP8 fix

Co-authored-by: Patrick Hurrelmann <patrick.hurrelmann@nfon.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-09-27 11:24:19 -04:00
Jeremy Stretch
cbf928f363 Merge pull request #10482 from phurrelmann/10480-fix-link-target-on-cable-svg
Fixes: #10480 Fix link-target on cable-trace svg
2022-09-27 11:07:14 -04:00
Patrick Hurrelmann
43b18c13e3 Fixes: #10480 Fix link-target on cable-trace svg to open link in the same window. 2022-09-27 13:23:51 +02:00
jeremystretch
dda193247a Fixes #10470: Omit read-only custom fields from CSV import forms 2022-09-26 16:47:34 -04:00
jeremystretch
2463e4efd3 Fixes #10461: Enable filtering by read-only custom fields in the UI 2022-09-26 16:42:11 -04:00
jeremystretch
a0b17887fd Fixes #10445: Avoid rounding virtual machine memory values 2022-09-26 15:45:58 -04:00
jeremystretch
96784640e3 Changelog for #10435, #10439 2022-09-26 10:27:35 -04:00
Jeremy Stretch
b75d12fe05 Merge pull request #10442 from netbox-community/10435-untagged-vlan
10435 check if vm.cluster in qs
2022-09-26 10:25:48 -04:00
Jeremy Stretch
5e389c32ed Merge pull request #10463 from netbox-community/revert-10410-10408-add-contact
Revert "10408 add error message if already exists"
2022-09-26 10:24:54 -04:00
Jeremy Stretch
fd89ef04b6 Revert "10408 add error message if already exists" 2022-09-26 10:24:40 -04:00
Jeremy Stretch
abcc10e938 Merge pull request #10410 from netbox-community/10408-add-contact
10408 add error message if already exists
2022-09-26 10:24:23 -04:00
jeremystretch
3ad337dd15 Filter VLANs and VLANGroups by site or cluster site for VM 2022-09-26 10:08:54 -04:00
Jeremy Stretch
a527767caa Merge pull request #10455 from miaow2/10439-airlow-widget
10439 Add widget for Airflow field in DeviceTypeForm
2022-09-26 09:20:48 -04:00
Arthur Hanson
39129ecedf 10407 fix documentation link to requests (#10409)
* 10407 fix documentation link to requests

* Append page heading to URL

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-09-26 09:17:02 -04:00
Artem I. Kotik
c97d2d4fe9 Add widget for Airflow field in DeviceTypeForm 2022-09-24 15:49:23 +04:00
Arthur
7735634649 10435 check if vm.cluster in qs 2022-09-22 10:34:37 -07:00
Jonathan Senecal
148c6a6c23 Merge pull request #10432 from netbox-community/10431-pylance-is-no-longer-working-by-default-in-vscode
Add [tool.pyright] to pyproject.toml and fix #10431
2022-09-21 15:33:25 -04:00
Jonathan Senecal
360172cad0 Add [tool.pyright] to pyproject.toml 2022-09-21 15:19:40 -04:00
Daniel Sheppard
75c91232b4 Update changelog for #9497 2022-09-20 09:49:46 -05:00
Daniel Sheppard
0190c0225e Merge pull request #10420 from netbox-community/9497-fix-site-location-nonracked-device-display
Fixes #9497 - Change non-racked filter for sites/locations
2022-09-20 09:48:09 -05:00
Daniel Sheppard
86d366be4d Fixes #9651 - Document Pre-Change process for scripts 2022-09-20 09:46:23 -05:00
Daniel Sheppard
71d71a6b1b Fixes #9497 - Change filter for sites/locations 2022-09-20 09:26:40 -05:00
Arthur
695ad47fe9 10408 add error message if already exists 2022-09-19 10:46:16 -07:00
jeremystretch
1b62c11db5 PRVB 2022-09-16 13:41:09 -04:00
Jeremy Stretch
83a66a672d Merge pull request #10391 from netbox-community/develop
Release v3.3.4
2022-09-16 13:39:06 -04:00
jeremystretch
30b9ddc251 Release v3.3.4 2022-09-16 13:14:14 -04:00
jeremystretch
4a9831bd23 Fixes #10383: Fix assignment of component templates to module types via web UI 2022-09-16 08:30:46 -04:00
jeremystretch
59388d89a0 Fixes #10387: Fix MultiValueDictKeyError exception when editing a device interface 2022-09-16 08:20:24 -04:00
jeremystretch
1d033bd286 Update lock threads action; lock closed PRs after 30 days 2022-09-15 16:08:43 -04:00
jeremystretch
935f008c16 PRVB 2022-09-15 14:29:43 -04:00
Arthur Hanson
b5d57f3418 Merge pull request #10381 from netbox-community/develop
Release v3.3.3
2022-09-15 11:18:08 -07:00
Arthur
05b17a0082 Release v3.3.3 2022-09-15 11:04:12 -07:00
jeremystretch
91fd25a548 Fixes #10353: Table action buttons should reserve return URL parameters 2022-09-15 13:40:39 -04:00
jeremystretch
e05696dfcc Fixes #10337: Display SSO links when local authentication fails 2022-09-15 13:17:04 -04:00
Jeremy Stretch
157a45b627 Merge pull request #10369 from netbox-community/10359-add-region-column
#10359 add region column to site table
2022-09-15 13:07:59 -04:00
jeremystretch
e9a91455e8 #10359: Add region and site group columns to the devices table 2022-09-15 12:55:21 -04:00
jeremystretch
f97eb99950 Changelog for #10247, #10258, #10305, #10362 2022-09-15 10:14:16 -04:00
Jeremy Stretch
c4b7ab067a Fixes #10247: Allow changing selected device/VM when creating a new component (#10312)
* Initial work on #10247

* Continued work on #10247

* Clean up component creation tests

* Move valdiation of replicated field to form

* Clean up ordering of fields in component creation forms

* Omit fieldset header if none

* Clean up ordering of fields in component template creation forms

* View tests should not move component templates to new device type

* Define replication_fields on VMInterfaceCreateForm

* Clean up expandable field help texts

* Update comments

* Update component bulk update forms & views to support new replication fields

* Fix ModularDeviceComponentForm parent class

* Fix bulk creation of VM interfaces (thanks @kkthxbye-code!)
2022-09-15 10:10:32 -04:00
Jeremy Stretch
7477f6584e Merge pull request #10370 from kvedder-amplex/develop
add custom fields to l2vpntermination edit template
2022-09-15 10:07:27 -04:00
Jeremy Stretch
7b4f5252f1 Merge pull request #10290 from kkthxbye-code/10258-nested-module-scripts
Fixes #10258 - Allow running scripts nested in modules/packages
2022-09-15 10:06:28 -04:00
kvedder
4e03419e85 add custom fields to l2vpntermination edit template 2022-09-14 22:15:12 -04:00
Arthur
4d97043e26 #10359 add region column to site table 2022-09-14 15:50:45 -07:00
kkthxbye-code
c335b76ec6 PEP8: Fix whitespace on blank line 2022-09-14 20:00:12 +02:00
kkthxbye-code
356ff457be Allow reports to be nested in submodules 2022-09-14 19:57:37 +02:00
Jeremy Stretch
833c3fbd39 Merge pull request #10349 from sasschary/10316-cableform-termination
Fixes #10305: Allows null master in VirtualChassis APIs
2022-09-14 12:13:16 -04:00
Zachary Clark
ace66eab61 Fixes #10305: Allows null master in VirtualChassis APIs 2022-09-13 19:54:39 -04:00
jeremystretch
ea9d2e3f88 Closes #9577: Add has_front_image and has_rear_image filters for device types 2022-09-13 14:14:18 -04:00
jeremystretch
211a1394d3 Changelog for #8580, #10333 2022-09-12 17:17:53 -04:00
Jeremy Stretch
44032ffc11 Merge pull request #10291 from netbox-community/8580-filter-connected
#8580 add interface filters for connected
2022-09-12 17:16:28 -04:00
jeremystretch
f10460d774 Add relevant tests for all device components 2022-09-12 17:03:33 -04:00
jeremystretch
57365ef7b9 Rename is_occupied to occupied 2022-09-12 16:42:04 -04:00
Arthur
d24f10ce6e #8580 add tests 2022-09-12 10:52:40 -07:00
Arthur
1daa2ff98d #8580 add tests 2022-09-12 10:22:05 -07:00
Arthur
0e6a6b784d Merge branch 'develop' into 8580-filter-connected 2022-09-12 10:11:20 -07:00
Jeremy Stretch
e3576e2614 Merge pull request #10338 from jsenecal/10298-pyproject-toml
Introduce pyproject.toml
2022-09-12 12:56:41 -04:00
Jeremy Stretch
0899d7aefd Merge pull request #10335 from netbox-community/10333-customfield-csv-ui_visibility
Fixes #10333: Show available values for ui_visibility field of CustomField for CSV import
2022-09-12 12:56:10 -04:00
Jonathan Senecal
d078befd33 Introduce pyproject.toml
- Tweaked to not break the existing codebase... too much
2022-09-12 11:48:26 -04:00
jeremystretch
f67cb71dbc Fixes #10333: Show available values for ui_visibility field of CustomField for CSV import 2022-09-12 10:36:19 -04:00
Arthur Hanson
721cd578bb 10310 pre commit yarn (#10315)
* #10310 run yarn pre-commit only if static files changed
2022-09-12 09:16:17 -04:00
jeremystretch
3aac62caa7 Changelog for #10304, #10307 2022-09-09 17:08:12 -04:00
Jeremy Stretch
6b9eb57de7 Merge pull request #10313 from netbox-community/10304-customfield-clone
Fixes #10304: Enable cloning for custom fields & custom links
2022-09-09 17:04:01 -04:00
jeremystretch
2b2a41edd2 Enable cloning for custom fields & custom links 2022-09-09 16:51:18 -04:00
jeremystretch
cd1ad452da Move clone() to CloningMixin 2022-09-09 16:44:58 -04:00
Jeremy Stretch
77868a9b17 Merge pull request #10309 from netbox-community/10307-fix-poe-type
#10307 fix choices for poe type
2022-09-09 16:20:21 -04:00
Arthur
59a2a43473 #10307 fix choices for poe type 2022-09-09 07:40:01 -07:00
jeremystretch
385a0f979e Changelog for #10250, #10294 2022-09-08 16:50:16 -04:00
Jeremy Stretch
770cc5a700 Merge pull request #10287 from netbox-community/10250-cable-import-virtual
Fixes #10250: Fix exception when CableTermination validation fails during bulk import of cables
2022-09-08 16:48:02 -04:00
Jeremy Stretch
a03155432e Merge pull request #10286 from netbox-community/10259-prefix-flat-link
Fixes #10259: Fix NoReverseMatch exception when listing available prefixes with "flat" column displayed
2022-09-08 16:47:27 -04:00
Arthur
d51e833bf3 #8580 changes from code review 2022-09-08 13:11:17 -07:00
jeremystretch
c6644ec1ae Annotate upgrade paths in upgrade documentation 2022-09-08 10:17:46 -04:00
Jeremy Stretch
a7562a6aa1 Merge pull request #10296 from kkthxbye-code/10294-fix-wwn-changelog
Fixes #10294 - Don't return empty string for empty wwn form field
2022-09-08 09:32:53 -04:00
jeremystretch
6b70436e2b Add Repography stats 2022-09-08 09:18:45 -04:00
kkthxbye-code
3dbc7bdd2c Add wwn CharField to InterfaceForm 2022-09-08 11:38:39 +02:00
Arthur
b4877e7fac #8580 add interface filters for connected 2022-09-07 15:45:01 -07:00
kkthxbye-code
f489ffa043 Allow running scripts nested in modules/packages 2022-09-07 22:33:24 +02:00
jeremystretch
211c7641c1 Fixes #10250: Fix exception when CableTermination validation fails during bulk import of cables 2022-09-07 14:41:48 -04:00
jeremystretch
51d066a1bc Fixes #10259: Fix NoReverseMatch exception when listing available prefixes with "flat" column displayed 2022-09-07 14:30:51 -04:00
jeremystretch
b702822857 Closes #10268: Omit trailing ".0" in device positions within UI 2022-09-07 14:09:17 -04:00
jeremystretch
1cbb2320c1 Changelog for #9231, #10270, #10278 2022-09-07 13:59:00 -04:00
Jeremy Stretch
1c4ad3f817 Merge pull request #10285 from netbox-community/10278-rack-photo-add-another
#10278 fix attaching an image to a rack "Create & Add Another"
2022-09-07 13:56:13 -04:00
Jeremy Stretch
ef5ec06141 Merge pull request #10283 from netbox-community/10270-save-custom-boolean
#10270 - fix custom field validation for IPAM services
2022-09-07 13:54:24 -04:00
Jeremy Stretch
0c37236d60 Merge pull request #10281 from netbox-community/9231-empty-search
9231 fix empty search
2022-09-07 13:53:33 -04:00
Arthur
def853e8c4 #10278 add get_extra_addanother_params 2022-09-07 10:40:24 -07:00
Arthur
7826cfb01f #10270 - fix custom field validation for ipam services 2022-09-07 08:45:56 -07:00
jeremystretch
ac8f0a7ef2 Add installation video to docs 2022-09-07 11:23:13 -04:00
Arthur
48a907ae45 #9231 add comment 2022-09-07 08:09:28 -07:00
Arthur
2fe620df70 #9231 call class method 2022-09-06 18:04:29 -07:00
Arthur
536bd37d05 #9231 make empty search work 2022-09-06 16:37:52 -07:00
jeremystretch
d29d265b0a PRVB 2022-09-02 15:44:41 -04:00
190 changed files with 4041 additions and 4048 deletions

View File

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

View File

@@ -19,11 +19,15 @@ body:
label: Area
description: To what section of the documentation does this change primarily pertain?
options:
- Installation instructions
- Configuration parameters
- Functionality/features
- REST API
- Administration/development
- Features
- Installation/upgrade
- Getting started
- Configuration
- Customization
- Integrations/API
- Plugins
- Administration
- Development
- Other
validations:
required: true

View File

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

View File

@@ -1,13 +1,14 @@
<!--
Thank you for your interest in contributing to NetBox! Please note that
our contribution policy requires that a feature request or bug report be
approved and assigned prior to filing a pull request. This helps avoid
wasting time and effort on something that we might not be able to accept.
approved and assigned prior to opening a pull request. This helps avoid
waste time and effort on a proposed change that we might not be able to
accept.
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
TO YOU, IT WE BE CLOSED AUTOMATICALLY.
TO YOU, IT WILL BE CLOSED AUTOMATICALLY.
Specify your assigned issue number on the line below.
Please specify your assigned issue number on the line below.
-->
### Fixes: #1234

View File

@@ -1,5 +1,7 @@
name: CI
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -4,18 +4,18 @@ name: 'Lock threads'
on:
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v2
- uses: dessant/lock-threads@v3
with:
github-token: ${{ github.token }}
issue-lock-inactive-days: '90'
issue-exclude-created-before: ''
issue-exclude-labels: ''
issue-lock-labels: ''
issue-lock-comment: ''
issue-inactive-days: 90
pr-inactive-days: 30
issue-lock-reason: 'resolved'
process-only: 'issues'

View File

@@ -1,14 +1,21 @@
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
name: 'Close stale issues/PRs'
on:
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v5
- uses: actions/stale@v6
with:
close-issue-message: >
This issue has been automatically closed due to lack of activity. In an

View File

@@ -2,8 +2,6 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</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,
@@ -11,6 +9,16 @@ NetBox provides the ideal "source of truth" to power network automation.
Available as open source software under the Apache 2.0 license, NetBox is
employed by thousands of organizations around the world.
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits)
[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues)
[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls)
[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors)
<br />Stats via [Repography](https://repography.com)
## About NetBox
![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
Myriad infrastructure components can be modeled in NetBox, including:
@@ -57,7 +65,7 @@ complete list of requirements, see `requirements.txt`. The code is available
[on GitHub](https://github.com/netbox-community/netbox).
<div align="center">
<h4>Thank you to our sponsors!</h4>
<h3>Thank you to our sponsors!</h3>
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;

View File

@@ -68,7 +68,7 @@ drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django
graphene_django
graphene_django<3.0
# WSGI HTTP server
# https://gunicorn.org/
@@ -80,7 +80,8 @@ Jinja2
# Simple markup language for rendering HTML
# https://github.com/Python-Markdown/markdown
Markdown
# mkdocs currently requires Markdown v3.3
Markdown<3.4
# File inclusion plugin for Python-Markdown
# https://github.com/cmacmackin/markdown-include

View File

@@ -2,8 +2,8 @@
{% block site_meta %}
{{ super() }}
{# Disable search indexing unless we're building for ReadTheDocs #}
{% if not config.extra.readthedocs %}
{# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
{% if page.canonical_url != 'https://docs.netbox.dev/' %}
<meta name="robots" content="noindex">
{% endif %}
{% endblock %}

View File

@@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
Default: None
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
```python
HTTP_PROXIES = {

View File

@@ -129,6 +129,19 @@ The Script object provides a set of convenient functions for recording messages
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
## Change Logging
To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.
```python
if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj.full_clean()
obj.save()
```
## Variable Reference
### Default Options

View File

@@ -45,7 +45,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined.
active = DeviceStatusChoices.STATUS_ACTIVE
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
if console_port.connected_endpoint is None:
if not console_port.connected_endpoints:
self.log_failure(
console_port.device,
"No console connection defined for {}".format(console_port.name)
@@ -64,7 +64,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:
if power_port.connected_endpoints:
connected_ports += 1
if not power_port.path.is_active:
self.log_warning(

View File

@@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo
Begin by installing all system packages required by NetBox and its dependencies.
!!! warning "Python 3.8 or later required"
NetBox v3.2 requires Python 3.8, 3.9, or 3.10.
NetBox requires Python 3.8, 3.9, or 3.10.
=== "Ubuntu"
@@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight
sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
```

View File

@@ -46,7 +46,7 @@ Next, create a file in the same directory as `configuration.py` (typically `/opt
### General Server Configuration
!!! info
When using Windows Server 2012 you may need to specify a port on `AUTH_LDAP_SERVER_URI`. Use `3269` for secure, or `3268` for non-secure.
When using Active Directory you may need to specify a port on `AUTH_LDAP_SERVER_URI` to authenticate users from all domains in the forest. Use `3269` for secure, or `3268` for non-secure access to the GC (Global Catalog).
```python
import ldap
@@ -67,6 +67,16 @@ AUTH_LDAP_BIND_PASSWORD = "demo"
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
LDAP_IGNORE_CERT_ERRORS = True
# Include this setting if you want to validate the LDAP server certificates against a CA certificate directory on your server
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, LDAP_CA_CERT_DIR)
LDAP_CA_CERT_DIR = '/etc/ssl/certs'
# Include this setting if you want to validate the LDAP server certificates against your own CA.
# Note that this is a NetBox-specific setting which sets:
# ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, LDAP_CA_CERT_FILE)
LDAP_CA_CERT_FILE = '/path/to/example-CA.crt'
```
STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the `ldap://` URI scheme.

View File

@@ -2,6 +2,8 @@
The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
<iframe width="560" height="315" src="https://www.youtube.com/embed/_y5JRiW_PLM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)

View File

@@ -1,10 +1,19 @@
# Upgrading to a New NetBox Release
## Review the Release Notes
Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade.
NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change).
[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png)
!!! warning "Perform a Backup"
Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process.
## 1. Review the Release Notes
Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect.
## Update Dependencies to Required Versions
## 2. Update Dependencies to Required Versions
NetBox v3.0 and later require the following:
@@ -14,7 +23,7 @@ NetBox v3.0 and later require the following:
| PostgreSQL | 10 |
| Redis | 4.0 |
## Install the Latest Release
## 3. Install the Latest Release
As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository.
@@ -87,7 +96,7 @@ sudo git pull origin master
sudo git checkout v2.11.11
## Run the Upgrade Script
## 4. Run the Upgrade Script
Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script:
@@ -118,7 +127,7 @@ This script performs the following actions:
been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are
intentionally modifying the database schema.
## Restart the NetBox Services
## 5. Restart the NetBox Services
!!! warning
If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference.
@@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services:
sudo systemctl restart netbox netbox-rq
```
## Verify Housekeeping Scheduling
## 6. Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -144,73 +144,73 @@ class MyModelFilterForm(NetBoxModelFilterSetForm):
In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below.
::: utilities.forms.ColorField
selection:
options:
members: false
::: utilities.forms.CommentField
selection:
options:
members: false
::: utilities.forms.JSONField
selection:
options:
members: false
::: utilities.forms.MACAddressField
selection:
options:
members: false
::: utilities.forms.SlugField
selection:
options:
members: false
## Choice Fields
::: utilities.forms.ChoiceField
selection:
options:
members: false
::: utilities.forms.MultipleChoiceField
selection:
options:
members: false
## Dynamic Object Fields
::: utilities.forms.DynamicModelChoiceField
selection:
options:
members: false
::: utilities.forms.DynamicModelMultipleChoiceField
selection:
options:
members: false
## Content Type Fields
::: utilities.forms.ContentTypeChoiceField
selection:
options:
members: false
::: utilities.forms.ContentTypeMultipleChoiceField
selection:
options:
members: false
## CSV Import Fields
::: utilities.forms.CSVChoiceField
selection:
options:
members: false
::: utilities.forms.CSVMultipleChoiceField
selection:
options:
members: false
::: utilities.forms.CSVModelChoiceField
selection:
options:
members: false
::: utilities.forms.CSVContentTypeField
selection:
options:
members: false
::: utilities.forms.CSVMultipleContentTypeField
selection:
options:
members: false

View File

@@ -32,11 +32,11 @@ schema = MyQuery
NetBox provides two object type classes for use by plugins.
::: netbox.graphql.types.BaseObjectType
selection:
options:
members: false
::: netbox.graphql.types.NetBoxObjectType
selection:
options:
members: false
## GraphQL Fields
@@ -44,9 +44,9 @@ NetBox provides two object type classes for use by plugins.
NetBox provides two field classes for use by plugins.
::: netbox.graphql.fields.ObjectField
selection:
options:
members: false
::: netbox.graphql.fields.ObjectListField
selection:
options:
members: false

View File

@@ -112,6 +112,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
!!! tip "Accessing Config Parameters"
Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example:
```python
from django.conf import settings
settings.PLUGINS_CONFIG['myplugin']['verbose_name']
```
## Create setup.py
`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:

View File

@@ -52,38 +52,38 @@ This will automatically apply any user-specific preferences for the table. (If u
The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`.
::: netbox.tables.BooleanColumn
selection:
options:
members: false
::: netbox.tables.ChoiceFieldColumn
selection:
options:
members: false
::: netbox.tables.ColorColumn
selection:
options:
members: false
::: netbox.tables.ColoredLabelColumn
selection:
options:
members: false
::: netbox.tables.ContentTypeColumn
selection:
options:
members: false
::: netbox.tables.ContentTypesColumn
selection:
options:
members: false
::: netbox.tables.MarkdownColumn
selection:
options:
members: false
::: netbox.tables.TagColumn
selection:
options:
members: false
::: netbox.tables.TemplateColumn
selection:
options:
members:
- __init__

View File

@@ -84,24 +84,24 @@ Below are the class definitions for NetBox's object views. These views handle CR
::: netbox.views.generic.base.BaseObjectView
::: netbox.views.generic.ObjectView
selection:
options:
members:
- get_object
- get_template_name
::: netbox.views.generic.ObjectEditView
selection:
options:
members:
- get_object
- alter_object
::: netbox.views.generic.ObjectDeleteView
selection:
options:
members:
- get_object
::: netbox.views.generic.ObjectChildrenView
selection:
options:
members:
- get_children
- prep_table_data
@@ -113,22 +113,22 @@ Below are the class definitions for NetBox's multi-object views. These views han
::: netbox.views.generic.base.BaseMultiObjectView
::: netbox.views.generic.ObjectListView
selection:
options:
members:
- get_table
- export_table
- export_template
::: netbox.views.generic.BulkImportView
selection:
options:
members: false
::: netbox.views.generic.BulkEditView
selection:
options:
members: false
::: netbox.views.generic.BulkDeleteView
selection:
options:
members:
- get_form
@@ -137,12 +137,12 @@ Below are the class definitions for NetBox's multi-object views. These views han
These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path.
::: netbox.views.generic.ObjectChangeLogView
selection:
options:
members:
- get_form
::: netbox.views.generic.ObjectJournalView
selection:
options:
members:
- get_form

View File

@@ -1,5 +1,166 @@
# NetBox v3.3
## v3.3.9 (2022-11-30)
### Enhancements
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
### Bug Fixes
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
---
## v3.3.8 (2022-11-16)
### Enhancements
* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
### Bug Fixes
* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
---
## v3.3.7 (2022-11-01)
### Bug Fixes
* [#10282](https://github.com/netbox-community/netbox/issues/10282) - Enforce advisory locks when allocating available IP addresses to prevent race conditions
* [#10770](https://github.com/netbox-community/netbox/issues/10282) - Fix social authentication for new users
* [#10791](https://github.com/netbox-community/netbox/issues/10791) - Permit nullifying VLAN group `scope_type` via REST API
* [#10803](https://github.com/netbox-community/netbox/issues/10803) - Fix exception when ordering contacts by number of assignments
* [#10809](https://github.com/netbox-community/netbox/issues/10809) - Permit nullifying site `time_zone` via REST API
---
## v3.3.6 (2022-10-26)
### Enhancements
* [#9584](https://github.com/netbox-community/netbox/issues/9584) - Enable filtering devices by device type slug
* [#9722](https://github.com/netbox-community/netbox/issues/9722) - Add LDAP configuration parameters to specify certificates
* [#10580](https://github.com/netbox-community/netbox/issues/10580) - Link "assigned" checkbox in IP address table to assigned interface
* [#10639](https://github.com/netbox-community/netbox/issues/10639) - Set cookie paths according to configured `BASE_PATH`
* [#10685](https://github.com/netbox-community/netbox/issues/10685) - Position A/Z termination cards above the fold under circuit view
### Bug Fixes
* [#9669](https://github.com/netbox-community/netbox/issues/9669) - Strip colons from usernames when using remote authentication
* [#10575](https://github.com/netbox-community/netbox/issues/10575) - Include OIDC dependencies for python-social-auth
* [#10584](https://github.com/netbox-community/netbox/issues/10584) - Fix service clone link
* [#10610](https://github.com/netbox-community/netbox/issues/10610) - Allow assignment of VC member to LAG on non-master peer
* [#10643](https://github.com/netbox-community/netbox/issues/10643) - Ensure consistent display of custom fields for all model forms
* [#10646](https://github.com/netbox-community/netbox/issues/10646) - Fix filtering of power feed by power panel when connecting a cable
* [#10655](https://github.com/netbox-community/netbox/issues/10655) - Correct display of assigned contacts in object tables
* [#10666](https://github.com/netbox-community/netbox/issues/10666) - Re-evaluate disabled LDAP user when processing API requests
* [#10682](https://github.com/netbox-community/netbox/issues/10682) - Correct home view links to connection lists
* [#10712](https://github.com/netbox-community/netbox/issues/10712) - Fix ModuleNotFoundError exception when generating API schema under Python 3.9+
* [#10716](https://github.com/netbox-community/netbox/issues/10716) - Add left/right page plugin content embeds for tag view
* [#10719](https://github.com/netbox-community/netbox/issues/10719) - Prevent user without sufficient permission from creating an IP address via FHRP group creation
* [#10723](https://github.com/netbox-community/netbox/issues/10723) - Distinguish between inside/outside NAT assignments for device/VM primary IPs
* [#10745](https://github.com/netbox-community/netbox/issues/10745) - Correct display of status field in clusters list
* [#10746](https://github.com/netbox-community/netbox/issues/10746) - Add missing status attribute to cluster view
---
## v3.3.5 (2022-10-05)
### Enhancements
* [#8424](https://github.com/netbox-community/netbox/issues/8424) - Include rack elevation under device view
* [#10352](https://github.com/netbox-community/netbox/issues/10352) - Omit extraneous URL query attributes during search
* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
### Bug Fixes
* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
* [#10423](https://github.com/netbox-community/netbox/issues/10423) - Enforce object type validation when creating journal entries
* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
* [#10460](https://github.com/netbox-community/netbox/issues/10460) - Restore missing connection details for device components
* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
* [#10491](https://github.com/netbox-community/netbox/issues/10491) - Clarify representation of blocking contact assignments during contact deletion
* [#10513](https://github.com/netbox-community/netbox/issues/10513) - Disable the reassignment of a module to a new device
* [#10517](https://github.com/netbox-community/netbox/issues/10517) - Automatically inherit site assignment from cluster when creating a virtual machine
* [#10559](https://github.com/netbox-community/netbox/issues/10559) - Permit the pinning of a VM to a particular device within a cluster which has no site assignment
* [#10562](https://github.com/netbox-community/netbox/issues/10562) - Correct URL for contacts table tags column
---
## v3.3.4 (2022-09-16)
### Bug Fixes
* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI
* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface
---
## v3.3.3 (2022-09-15)
### Enhancements
* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected`
* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types
* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI
* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table
### Bug Fixes
* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters
* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components
* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables
* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules
* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed
* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services
* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments
* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field
* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links
* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API
* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection
* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import
* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails
* [#10353](https://github.com/netbox-community/netbox/issues/10353) - Table action buttons should reserve return URL parameters
* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination
---
## v3.3.2 (2022-09-02)
### Enhancements

View File

@@ -30,7 +30,7 @@ plugins:
- os.chdir('netbox/')
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
- django.setup()
rendering:
options:
heading_level: 3
members_order: source
show_root_heading: true
@@ -38,7 +38,6 @@ plugins:
show_root_toc_entry: false
show_source: false
extra:
readthedocs: !ENV READTHEDOCS
social:
- icon: fontawesome/brands/github
link: https://github.com/netbox-community/netbox

View File

@@ -76,6 +76,12 @@ class ProviderNetworkForm(NetBoxModelForm):
class CircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Circuit Type', (
'name', 'slug', 'description', 'tags',
)),
)
class Meta:
model = CircuitType
fields = [

View File

@@ -1,8 +1,9 @@
import django_tables2 as tables
from circuits.models import *
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .columns import CommitRateColumn
__all__ = (
@@ -39,7 +40,7 @@ class CircuitTypeTable(NetBoxTable):
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name='Circuit ID'
@@ -58,9 +59,6 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable):
)
commit_rate = CommitRateColumn()
comments = columns.MarkdownColumn()
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)

View File

@@ -1,7 +1,8 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from circuits.models import *
from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin
from netbox.tables import NetBoxTable, columns
__all__ = (
@@ -10,7 +11,7 @@ __all__ = (
)
class ProviderTable(NetBoxTable):
class ProviderTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -31,9 +32,6 @@ class ProviderTable(NetBoxTable):
verbose_name='Circuits'
)
comments = columns.MarkdownColumn()
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='circuits:provider_list'
)

View File

@@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
)
Circuit.objects.bulk_create(circuits)
@@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'),
CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'),
CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'),
CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
@@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_term_side(self):
params = {'term_side': 'A'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
def test_port_speed(self):
params = {'port_speed': ['1000', '2000']}
@@ -397,11 +399,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_provider_network(self):
provider_networks = ProviderNetwork.objects.all()[:2]
params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_cabled(self):
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):

View File

@@ -130,7 +130,7 @@ class SiteSerializer(NetBoxModelSerializer):
region = NestedRegionSerializer(required=False, allow_null=True)
group = NestedSiteGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False)
time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField(
queryset=ASN.objects.all(),
serializer=NestedASNSerializer,
@@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False)
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
member_count = serializers.IntegerField(read_only=True)
class Meta:

View File

@@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
TYPE_10GE_KR = '10gbase-kr'
TYPE_10GE_KX4 = '10gbase-kx4'
TYPE_25GE_KR = '25gbase-kr'
TYPE_40GE_KR4 = '40gbase-kr4'
TYPE_50GE_KR = '50gbase-kr'
TYPE_100GE_KP4 = '100gbase-kp4'
TYPE_100GE_KR2 = '100gbase-kr2'
TYPE_100GE_KR4 = '100gbase-kr4'
# Wireless
TYPE_80211A = 'ieee802.11a'
TYPE_80211G = 'ieee802.11g'
@@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
)
),
(
'Ethernet (backplane)',
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
(TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
(TYPE_25GE_KR, '25GBASE-KR (25GE)'),
(TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
(TYPE_50GE_KR, '50GBASE-KR (50GE)'),
(TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
(TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
(TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
)
),
(
'Wireless',
(
@@ -1096,7 +1121,7 @@ class InterfacePoETypeChoices(ChoiceSet):
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'),
(PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'),
)
),
)

View File

@@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
has_front_image = django_filters.BooleanFilter(
label='Has a front image',
method='_has_front_image'
)
has_rear_image = django_filters.BooleanFilter(
label='Has a rear image',
method='_has_rear_image'
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label='Has console ports',
@@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
Q(comments__icontains=value)
)
def _has_front_image(self, queryset, name, value):
if value:
return queryset.exclude(front_image='')
else:
return queryset.filter(front_image='')
def _has_rear_image(self, queryset, name, value):
if value:
return queryset.exclude(rear_image='')
else:
return queryset.filter(rear_image='')
def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleporttemplates__isnull=value)
@@ -780,6 +800,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
to_field_name='slug',
label='Manufacturer (slug)',
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug',
queryset=DeviceType.objects.all(),
to_field_name='slug',
label='Device type (slug)',
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=DeviceType.objects.all(),
label='Device type (ID)',
@@ -1144,6 +1170,15 @@ class CabledObjectFilterSet(django_filters.FilterSet):
lookup_expr='isnull',
exclude=True
)
occupied = django_filters.BooleanFilter(
method='filter_occupied'
)
def filter_occupied(self, queryset, name, value):
if value:
return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True))
else:
return queryset.filter(cable__isnull=True, mark_connected=False)
class PathEndpointFilterSet(django_filters.FilterSet):
@@ -1328,7 +1363,7 @@ class InterfaceFilterSet(
try:
devices = Device.objects.filter(pk__in=id_list)
for device in devices:
vc_interface_ids += device.vc_interfaces().values_list('id', flat=True)
vc_interface_ids += device.vc_interfaces(if_master=False).values_list('id', flat=True)
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()

View File

@@ -3,7 +3,7 @@ from django import forms
from dcim.models import *
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model
from .object_create import ComponentCreateForm
__all__ = (
@@ -24,7 +24,7 @@ __all__ = (
# Device components
#
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
@@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
queryset=Tag.objects.all(),
required=False
)
replication_fields = ('name', 'label')
class ConsolePortBulkCreateForm(
@@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = ConsolePort
field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags')
field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags')
class ConsoleServerPortBulkCreateForm(
@@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = ConsoleServerPort
field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags')
field_order = ('name', 'label', 'type', 'speed', 'description', 'tags')
class PowerPortBulkCreateForm(
@@ -60,7 +61,7 @@ class PowerPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = PowerPort
field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags')
class PowerOutletBulkCreateForm(
@@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm(
DeviceBulkAddComponentForm
):
model = PowerOutlet
field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags')
field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags')
class InterfaceBulkCreateForm(
@@ -79,7 +80,7 @@ class InterfaceBulkCreateForm(
):
model = Interface
field_order = (
'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
)
@@ -96,13 +97,13 @@ class RearPortBulkCreateForm(
DeviceBulkAddComponentForm
):
model = RearPort
field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags')
class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
model = ModuleBay
field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags')
field_order = ('name', 'label', 'position_pattern', 'description', 'tags')
replication_fields = ('name', 'label', 'position')
position_pattern = ExpandableNameField(
label='Position',
required=False,
@@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
model = DeviceBay
field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
field_order = ('name', 'label', 'description', 'tags')
class InventoryItemBulkCreateForm(
@@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm(
):
model = InventoryItem
field_order = (
'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
)

View File

@@ -1218,7 +1218,7 @@ class FrontPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class RearPortBulkEditForm(
@@ -1229,7 +1229,7 @@ class RearPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class ModuleBayBulkEditForm(

View File

@@ -108,7 +108,7 @@ def get_cable_form(a_type, b_type):
label='Power Feed',
disabled_indicator='_occupied',
query_params={
'powerpanel_id': f'$termination_{cable_end}_powerpanel',
'power_panel_id': f'$termination_{cable_end}_powerpanel',
}
)

View File

@@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
(None, ('q', 'tag')),
('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')),
('Images', ('has_front_image', 'has_rear_image')),
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
@@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
has_front_image = forms.NullBooleanField(
required=False,
label='Has a front image',
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_rear_image = forms.NullBooleanField(
required=False,
label='Has a rear image',
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
@@ -936,12 +951,37 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
# Device components
#
class ConsolePortFilterForm(DeviceComponentFilterForm):
class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
occupied = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class PathEndpointFilterForm(CabledFilterForm):
connected = forms.NullBooleanField(
required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -954,12 +994,13 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsoleServerPort
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=ConsolePortTypeChoices,
@@ -972,12 +1013,13 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class PowerPortFilterForm(DeviceComponentFilterForm):
class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=PowerPortTypeChoices,
@@ -986,12 +1028,13 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class PowerOutletFilterForm(DeviceComponentFilterForm):
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
)
type = MultipleChoiceField(
choices=PowerOutletTypeChoices,
@@ -1000,7 +1043,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class InterfaceFilterForm(DeviceComponentFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
(None, ('q', 'tag')),
@@ -1009,6 +1052,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
('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')),
('Connection', ('cabled', 'connected', 'occupied')),
)
kind = MultipleChoiceField(
choices=InterfaceKindChoices,
@@ -1089,11 +1133,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class FrontPortFilterForm(DeviceComponentFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
)
model = FrontPort
type = MultipleChoiceField(
@@ -1106,12 +1151,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class RearPortFilterForm(DeviceComponentFilterForm):
class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
)
type = MultipleChoiceField(
choices=PortTypeChoices,

View File

@@ -78,6 +78,12 @@ class RegionForm(NetBoxModelForm):
)
slug = SlugField()
fieldsets = (
('Region', (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
class Meta:
model = Region
fields = (
@@ -92,6 +98,12 @@ class SiteGroupForm(NetBoxModelForm):
)
slug = SlugField()
fieldsets = (
('Site Group', (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
class Meta:
model = SiteGroup
fields = (
@@ -213,6 +225,12 @@ class LocationForm(TenancyForm, NetBoxModelForm):
class RackRoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Rack Role', (
'name', 'slug', 'color', 'description', 'tags',
)),
)
class Meta:
model = RackRole
fields = [
@@ -340,6 +358,12 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
class ManufacturerForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Manufacturer', (
'name', 'slug', 'description', 'tags',
)),
)
class Meta:
model = Manufacturer
fields = [
@@ -373,6 +397,7 @@ class DeviceTypeForm(NetBoxModelForm):
'front_image', 'rear_image', 'comments', 'tags',
]
widgets = {
'airflow': StaticSelect(),
'subdevice_role': StaticSelect(),
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
@@ -405,6 +430,12 @@ class ModuleTypeForm(NetBoxModelForm):
class DeviceRoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Device Role', (
'name', 'slug', 'color', 'vm_role', 'description', 'tags',
)),
)
class Meta:
model = DeviceRole
fields = [
@@ -421,6 +452,13 @@ class PlatformForm(NetBoxModelForm):
max_length=64
)
fieldsets = (
('Platform', (
'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
)
class Meta:
model = Platform
fields = [
@@ -678,6 +716,7 @@ class ModuleForm(NetBoxModelForm):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields['device'].disabled = True
self.fields['replicate_components'].initial = False
self.fields['replicate_components'].disabled = True
self.fields['adopt_components'].initial = False
@@ -838,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm):
'site_id': '$site'
}
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site'
},
initial_params={
'racks': '$rack'
}
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'location_id': '$location',
'site_id': '$site'
}
)
@@ -849,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm):
fieldsets = (
('Power Panel', ('region', 'site', 'power_panel')),
('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
)
class Meta:
model = PowerFeed
fields = [
'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
]
widgets = {
@@ -986,47 +1036,85 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
# Device component templates
#
class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of DeviceType when editing an existing instance
if self.instance.pk:
self.fields['device_type'].disabled = True
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all().all(),
required=False
)
module_type = DynamicModelChoiceField(
queryset=ModuleType.objects.all(),
required=False
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of ModuleType when editing an existing instance
if self.instance.pk:
self.fields['module_type'].disabled = True
class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
)
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect,
}
class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class ConsoleServerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')),
)
class Meta:
model = ConsoleServerPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect,
}
class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, (
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
)),
)
class Meta:
model = PowerPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class PowerOutletTemplateForm(ModularComponentTemplateForm):
power_port = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
@@ -1035,35 +1123,40 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
}
)
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')),
)
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')),
('PoE', ('poe_mode', 'poe_type'))
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
'poe_mode': StaticSelect(),
'poe_type': StaticSelect(),
}
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
class FrontPortTemplateForm(ModularComponentTemplateForm):
rear_port = DynamicModelChoiceField(
queryset=RearPortTemplate.objects.all(),
required=False,
@@ -1073,6 +1166,13 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
)
fieldsets = (
(None, (
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
)),
)
class Meta:
model = FrontPortTemplate
fields = [
@@ -1080,48 +1180,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class RearPortTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')),
)
class Meta:
model = RearPortTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
'type': StaticSelect(),
}
class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm):
class ModuleBayTemplateForm(ComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'name', 'label', 'position', 'description')),
)
class Meta:
model = ModuleBayTemplate
fields = [
'device_type', 'name', 'label', 'position', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
}
class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
class DeviceBayTemplateForm(ComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'name', 'label', 'description')),
)
class Meta:
model = DeviceBayTemplate
fields = [
'device_type', 'name', 'label', 'description',
]
widgets = {
'device_type': forms.HiddenInput(),
}
class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
class InventoryItemTemplateForm(ComponentTemplateForm):
parent = DynamicModelChoiceField(
queryset=InventoryItemTemplate.objects.all(),
required=False,
@@ -1148,22 +1250,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
widget=forms.HiddenInput
)
fieldsets = (
(None, (
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
)),
)
class Meta:
model = InventoryItemTemplate
fields = [
'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
'component_type', 'component_id',
]
widgets = {
'device_type': forms.HiddenInput(),
}
#
# Device components
#
class ConsolePortForm(NetBoxModelForm):
class DeviceComponentForm(NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Disable reassignment of Device when editing an existing instance
if self.instance.pk:
self.fields['device'].disabled = True
class ModularDeviceComponentForm(DeviceComponentForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
@@ -1172,25 +1291,31 @@ class ConsolePortForm(NetBoxModelForm):
}
)
class ConsolePortForm(ModularDeviceComponentForm):
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
)),
)
class Meta:
model = ConsolePort
fields = [
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
}
class ConsoleServerPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
class ConsoleServerPortForm(ModularDeviceComponentForm):
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
)),
)
class Meta:
@@ -1199,42 +1324,32 @@ class ConsoleServerPortForm(NetBoxModelForm):
'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
}
class PowerPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
class PowerPortForm(ModularDeviceComponentForm):
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
'description', 'tags',
)),
)
class Meta:
model = PowerPort
fields = [
'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
'description',
'tags',
'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
}
class PowerOutletForm(NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
)
class PowerOutletForm(ModularDeviceComponentForm):
power_port = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
@@ -1243,6 +1358,13 @@ class PowerOutletForm(NetBoxModelForm):
}
)
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
'tags',
)),
)
class Meta:
model = PowerOutlet
fields = [
@@ -1250,20 +1372,12 @@ class PowerOutletForm(NetBoxModelForm):
'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'feed_leg': StaticSelect(),
}
class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
)
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
@@ -1331,8 +1445,14 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
label='VRF'
)
wwn = forms.CharField(
empty_value=None,
required=False,
label='WWN'
)
fieldsets = (
('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')),
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
@@ -1352,7 +1472,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
@@ -1371,25 +1490,8 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'rf_channel_width': "Populated by selected channel (if set)",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Restrict LAG/bridge interface assignment by device/VC
device_id = self.data['device'] if self.is_bound else self.initial.get('device')
device = Device.objects.filter(pk=device_id).first()
if device and device.virtual_chassis and device.virtual_chassis.master:
self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
class FrontPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
)
class FrontPortForm(ModularDeviceComponentForm):
rear_port = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
query_params={
@@ -1397,6 +1499,13 @@ class FrontPortForm(NetBoxModelForm):
}
)
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected',
'description', 'tags',
)),
)
class Meta:
model = FrontPort
fields = [
@@ -1404,18 +1513,15 @@ class FrontPortForm(NetBoxModelForm):
'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
}
class RearPortForm(NetBoxModelForm):
module = DynamicModelChoiceField(
queryset=Module.objects.all(),
required=False,
query_params={
'device_id': '$device',
}
class RearPortForm(ModularDeviceComponentForm):
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
)),
)
class Meta:
@@ -1424,33 +1530,32 @@ class RearPortForm(NetBoxModelForm):
'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
}
class ModuleBayForm(NetBoxModelForm):
class ModuleBayForm(DeviceComponentForm):
fieldsets = (
(None, ('device', 'name', 'label', 'position', 'description', 'tags',)),
)
class Meta:
model = ModuleBay
fields = [
'device', 'name', 'label', 'position', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
}
class DeviceBayForm(NetBoxModelForm):
class DeviceBayForm(DeviceComponentForm):
fieldsets = (
(None, ('device', 'name', 'label', 'description', 'tags',)),
)
class Meta:
model = DeviceBay
fields = [
'device', 'name', 'label', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
}
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
@@ -1473,10 +1578,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
).exclude(pk=device_bay.device.pk)
class InventoryItemForm(NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
class InventoryItemForm(DeviceComponentForm):
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
@@ -1523,6 +1625,12 @@ class InventoryItemForm(NetBoxModelForm):
class InventoryItemRoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Inventory Item Role', (
'name', 'slug', 'color', 'description', 'tags',
)),
)
class Meta:
model = InventoryItemRole
fields = [

View File

@@ -2,46 +2,56 @@ from django import forms
from dcim.models import *
from netbox.forms import NetBoxModelForm
from utilities.forms import (
BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
)
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
from . import models as model_forms
__all__ = (
'ComponentTemplateCreateForm',
'DeviceComponentCreateForm',
'ComponentCreateForm',
'ConsolePortCreateForm',
'ConsolePortTemplateCreateForm',
'ConsoleServerPortCreateForm',
'ConsoleServerPortTemplateCreateForm',
'DeviceBayCreateForm',
'DeviceBayTemplateCreateForm',
'FrontPortCreateForm',
'FrontPortTemplateCreateForm',
'InterfaceCreateForm',
'InterfaceTemplateCreateForm',
'InventoryItemCreateForm',
'ModularComponentTemplateCreateForm',
'InventoryItemTemplateCreateForm',
'ModuleBayCreateForm',
'ModuleBayTemplateCreateForm',
'PowerOutletCreateForm',
'PowerOutletTemplateCreateForm',
'PowerPortCreateForm',
'PowerPortTemplateCreateForm',
'RearPortCreateForm',
'RearPortTemplateCreateForm',
'VirtualChassisCreateForm',
)
class ComponentCreateForm(BootstrapMixin, forms.Form):
class ComponentCreateForm(forms.Form):
"""
Subclass this form when facilitating the creation of one or more device component or component templates based on
Subclass this form when facilitating the creation of one or more component or component template objects based on
a name pattern.
"""
name_pattern = ExpandableNameField(
label='Name'
)
label_pattern = ExpandableNameField(
label='Label',
name = ExpandableNameField()
label = ExpandableNameField(
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
)
# Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by
# ComponentCreateView when creating objects.
replication_fields = ('name', 'label')
def clean(self):
super().clean()
# Validate that all patterned fields generate an equal number of values
patterned_fields = [
field_name for field_name in self.fields if field_name.endswith('_pattern')
]
pattern_count = len(self.cleaned_data['name_pattern'])
for field_name in patterned_fields:
# Validate that all replication fields generate an equal number of values
pattern_count = len(self.cleaned_data[self.replication_fields[0]])
for field_name in self.replication_fields:
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
@@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form):
}, code='label_pattern_mismatch')
class ComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned only to a DeviceType.
"""
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
)
field_order = ('device_type', 'name_pattern', 'label_pattern')
#
# Device component templates
#
class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm):
class Meta(model_forms.ConsolePortTemplateForm.Meta):
exclude = ('name', 'label')
class ModularComponentTemplateCreateForm(ComponentCreateForm):
"""
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
"""
name_pattern = ExpandableNameField(
label='Name',
help_text="""
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
the module bay position.
"""
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
required=False
)
module_type = DynamicModelChoiceField(
queryset=ModuleType.objects.all(),
required=False
)
field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern')
class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm):
class Meta(model_forms.ConsoleServerPortTemplateForm.Meta):
exclude = ('name', 'label')
class DeviceComponentCreateForm(ComponentCreateForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
field_order = ('device', 'name_pattern', 'label_pattern')
class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm):
class Meta(model_forms.PowerPortTemplateForm.Meta):
exclude = ('name', 'label')
class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
rear_port_set = forms.MultipleChoiceField(
class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm):
class Meta(model_forms.PowerOutletTemplateForm.Meta):
exclude = ('name', 'label')
class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
class Meta(model_forms.InterfaceTemplateForm.Meta):
exclude = ('name', 'label')
class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
rear_port = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'device_type', 'name_pattern', 'label_pattern', 'rear_port_set',
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')),
)
class Meta(model_forms.FrontPortTemplateForm.Meta):
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port_set'].choices = choices
self.fields['rear_port'].choices = choices
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return {
'rear_port': int(rear_port),
@@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
}
class FrontPortCreateForm(DeviceComponentCreateForm):
rear_port_set = forms.MultipleChoiceField(
class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm):
class Meta(model_forms.RearPortTemplateForm.Meta):
exclude = ('name', 'label')
class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm):
class Meta(model_forms.DeviceBayTemplateForm.Meta):
exclude = ('name', 'label')
class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm):
position = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
)
replication_fields = ('name', 'label', 'position')
class Meta(model_forms.ModuleBayTemplateForm.Meta):
exclude = ('name', 'label', 'position')
class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm):
class Meta(model_forms.InventoryItemTemplateForm.Meta):
exclude = ('name', 'label')
#
# Device components
#
class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm):
class Meta(model_forms.ConsolePortForm.Meta):
exclude = ('name', 'label')
class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm):
class Meta(model_forms.ConsoleServerPortForm.Meta):
exclude = ('name', 'label')
class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm):
class Meta(model_forms.PowerPortForm.Meta):
exclude = ('name', 'label')
class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
class Meta(model_forms.PowerOutletForm.Meta):
exclude = ('name', 'label')
class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
class Meta(model_forms.InterfaceForm.Meta):
exclude = ('name', 'label')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'module' in self.fields:
self.fields['name'].help_text += ' The string <code>{module}</code> will be replaced with the position ' \
'of the assigned module, if any'
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
rear_port = forms.MultipleChoiceField(
choices=[],
label='Rear ports',
help_text='Select one rear port assignment for each front port being created.',
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'rear_port_set',
# Override fieldsets from FrontPortForm to omit rear_port_position
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags',
)),
)
class Meta(model_forms.FrontPortForm.Meta):
exclude = ('name', 'label', 'rear_port', 'rear_port_position')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
choices.append(
('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
)
self.fields['rear_port_set'].choices = choices
self.fields['rear_port'].choices = choices
def get_iterative_data(self, iteration):
# Assign rear port and position from selected set
rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
rear_port, position = self.cleaned_data['rear_port'][iteration].split(':')
return {
'rear_port': int(rear_port),
@@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm):
}
class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
position_pattern = ExpandableNameField(
class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm):
class Meta(model_forms.RearPortForm.Meta):
exclude = ('name', 'label')
class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm):
class Meta(model_forms.DeviceBayForm.Meta):
exclude = ('name', 'label')
class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm):
position = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)'
)
field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern')
replication_fields = ('name', 'label', 'position')
class Meta(model_forms.ModuleBayForm.Meta):
exclude = ('name', 'label', 'position')
class ModuleBayCreateForm(DeviceComponentCreateForm):
position_pattern = ExpandableNameField(
label='Position',
required=False,
help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)'
)
field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern')
class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm):
class Meta(model_forms.InventoryItemForm.Meta):
exclude = ('name', 'label')
class InventoryItemCreateForm(ComponentCreateForm):
# Device is assigned by the model form
field_order = ('name_pattern', 'label_pattern')
#
# Virtual chassis
#
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(

View File

@@ -279,17 +279,24 @@ class CableTermination(models.Model):
def clean(self):
super().clean()
# Check for existing termination
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
termination_type=self.termination_type,
termination_id=self.termination_id
).first()
if existing_termination is not None:
raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
f"{self.termination_id}: cable {existing_termination.cable.pk}"
)
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces'
})
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
# A CircuitTermination attached to a ProviderNetwork cannot have a Cable
if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
raise ValidationError({
'termination': "Circuit terminations attached to a provider network may not be cabled."
})
raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
def save(self, *args, **kwargs):
@@ -574,6 +581,7 @@ class CablePath(models.Model):
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
is_complete = True
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site

View File

@@ -189,7 +189,7 @@ class PathEndpoint(models.Model):
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
"""
_path = models.ForeignKey(
to='dcim.CablePath',
@@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
def clean(self):
super().clean()
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
})
if hasattr(self, 'rear_port'):
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
})
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
})
class RearPort(ModularComponentModel, CabledObjectModel):

View File

@@ -987,6 +987,14 @@ class Module(NetBoxModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:module', args=[self.pk])
def clean(self):
super().clean()
if self.module_bay.device != self.device:
raise ValidationError(
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
)
def save(self, *args, **kwargs):
is_new = self.pk is None

View File

@@ -477,6 +477,8 @@ class RackReservation(NetBoxModel):
max_length=200
)
clone_fields = ('rack', 'user', 'tenant')
class Meta:
ordering = ['created', 'pk']

View File

@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@@ -123,3 +125,14 @@ def nullify_connected_endpoints(instance, **kwargs):
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
cablepath.retrace()
@receiver(post_save, sender=FrontPort)
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
"""
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
"""
if created and not raw:
rearport = instance.rear_port
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
cablepath.retrace()

View File

@@ -35,7 +35,7 @@ class Node(Hyperlink):
"""
def __init__(self, position, width, url, color, labels, radius=10, **extra):
super(Node, self).__init__(href=url, target='_blank', **extra)
super(Node, self).__init__(href=url, target='_parent', **extra)
x, y = position
@@ -166,7 +166,7 @@ class CableTraceSVG:
"""
if hasattr(instance, 'parent_object'):
# Termination
return 'f0f0f0'
return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
if hasattr(instance, 'device_role'):
# Device
return instance.device_role.color

View File

@@ -9,6 +9,7 @@ from svgwrite.text import Text
from django.conf import settings
from django.core.exceptions import FieldError
from django.db.models import Q
from django.template.defaultfilters import floatformat
from django.urls import reverse
from django.utils.http import urlencode
@@ -41,7 +42,7 @@ def get_device_description(device):
device.device_role,
device.device_type.manufacturer.name,
device.device_type.model,
device.device_type.u_height,
floatformat(device.device_type.u_height),
device.asset_tag or '',
device.serial or ''
)

View File

@@ -1,12 +1,26 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import (
ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
ConsolePort,
ConsoleServerPort,
Device,
DeviceBay,
DeviceRole,
FrontPort,
Interface,
InventoryItem,
InventoryItemRole,
ModuleBay,
Platform,
PowerOutlet,
PowerPort,
RearPort,
VirtualChassis,
)
from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import *
__all__ = (
@@ -137,12 +151,21 @@ class PlatformTable(NetBoxTable):
# Devices
#
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
)
status = columns.ChoiceFieldColumn()
region = tables.Column(
accessor=Accessor('site__region'),
linkify=True
)
site_group = tables.Column(
accessor=Accessor('site__group'),
linkify=True,
verbose_name='Site Group'
)
site = tables.Column(
linkify=True
)
@@ -152,6 +175,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
rack = tables.Column(
linkify=True
)
position = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
device_role = columns.ColoredLabelColumn(
verbose_name='Role'
)
@@ -189,9 +215,6 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
verbose_name='VC Priority'
)
comments = columns.MarkdownColumn()
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:device_list'
)
@@ -199,10 +222,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Device
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
'created', 'last_updated',
'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', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',

View File

@@ -1,10 +1,22 @@
import django_tables2 as tables
from dcim.models import (
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
ConsolePortTemplate,
ConsoleServerPortTemplate,
DeviceBayTemplate,
DeviceType,
FrontPortTemplate,
InterfaceTemplate,
InventoryItemTemplate,
Manufacturer,
ModuleBayTemplate,
PowerOutletTemplate,
PowerPortTemplate,
RearPortTemplate,
)
from tenancy.tables import ContactsColumnMixin
from netbox.tables import NetBoxTable, columns
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
__all__ = (
@@ -27,7 +39,7 @@ __all__ = (
# Manufacturers
#
class ManufacturerTable(NetBoxTable):
class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -43,9 +55,6 @@ class ManufacturerTable(NetBoxTable):
verbose_name='Platforms'
)
slug = tables.Column()
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:manufacturer_list'
)
@@ -85,6 +94,9 @@ class DeviceTypeTable(NetBoxTable):
tags = columns.TagColumn(
url_name='dcim:devicetype_list'
)
u_height = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
class Meta(NetBoxTable.Meta):
model = DeviceType

View File

@@ -1,7 +1,9 @@
import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel
from tenancy.tables import ContactsColumnMixin
from netbox.tables import NetBoxTable, columns
from .devices import CableTerminationTable
__all__ = (
@@ -14,7 +16,7 @@ __all__ = (
# Power panels
#
class PowerPanelTable(NetBoxTable):
class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -29,9 +31,6 @@ class PowerPanelTable(NetBoxTable):
url_params={'power_panel_id': 'pk'},
verbose_name='Feeds'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:powerpanel_list'
)

View File

@@ -1,9 +1,9 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
__all__ = (
'RackTable',
@@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable):
# Racks
#
class RackTable(TenancyColumnsMixin, NetBoxTable):
class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
order_by=('_name',),
linkify=True
@@ -68,9 +68,6 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name='Power'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:rack_list'
)

View File

@@ -1,8 +1,9 @@
import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
from tenancy.tables import TenancyColumnsMixin
from .template_code import LOCATION_BUTTONS
__all__ = (
@@ -17,7 +18,7 @@ __all__ = (
# Regions
#
class RegionTable(NetBoxTable):
class RegionTable(ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -26,9 +27,6 @@ class RegionTable(NetBoxTable):
url_params={'region_id': 'pk'},
verbose_name='Sites'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:region_list'
)
@@ -46,7 +44,7 @@ class RegionTable(NetBoxTable):
# Site groups
#
class SiteGroupTable(NetBoxTable):
class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -55,9 +53,6 @@ class SiteGroupTable(NetBoxTable):
url_params={'group_id': 'pk'},
verbose_name='Sites'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:sitegroup_list'
)
@@ -75,7 +70,7 @@ class SiteGroupTable(NetBoxTable):
# Sites
#
class SiteTable(TenancyColumnsMixin, NetBoxTable):
class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.Column(
linkify=True
)
@@ -97,9 +92,6 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
verbose_name='ASN Count'
)
comments = columns.MarkdownColumn()
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:site_list'
)
@@ -118,7 +110,7 @@ class SiteTable(TenancyColumnsMixin, NetBoxTable):
# Locations
#
class LocationTable(TenancyColumnsMixin, NetBoxTable):
class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = columns.MPTTColumn(
linkify=True
)
@@ -136,9 +128,6 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable):
url_params={'location_id': 'pk'},
verbose_name='Devices'
)
contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
url_name='dcim:location_list'
)

View File

@@ -239,7 +239,7 @@ INTERFACE_BUTTONS = """
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ record.device_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Inventory Item</a></li>
{% endif %}
{% if perms.dcim.add_interface %}
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name_pattern={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ record.device_id }}&parent={{ record.pk }}&name={{ record.name }}.&type=virtual&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Child Interface</a></li>
{% endif %}
{% if perms.ipam.add_l2vpntermination %}
<li><a class="dropdown-item" href="{% url 'ipam:l2vpntermination_add' %}?device={{ object.pk }}&interface={{ record.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">L2VPN Termination</a></li>

View File

@@ -2057,6 +2057,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
cls.bulk_update_data = {
'domain': 'newdomain',
'master': None
}

View File

@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1
cable1.delete()

View File

@@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
Manufacturer.objects.bulk_create(manufacturers)
device_types = (
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True),
DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'),
DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR),
DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT),
)
@@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_full_depth(self):
params = {'is_full_depth': 'true'}
params = {'is_full_depth': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'is_full_depth': 'false'}
params = {'is_full_depth': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_subdevice_role(self):
@@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_front_image(self):
params = {'has_front_image': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'has_front_image': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_has_rear_image(self):
params = {'has_rear_image': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'has_rear_image': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_console_ports(self):
params = {'console_ports': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1631,6 +1643,8 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
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].slug, device_types[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_devicerole(self):
device_roles = DeviceRole.objects.all()[:2]
@@ -1983,12 +1997,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2037,9 +2045,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2144,12 +2164,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['First', 'Second']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2198,9 +2212,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2313,12 +2339,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'allocated_draw': [50, 100]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2367,9 +2387,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2478,12 +2510,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -2532,9 +2558,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -2741,12 +2779,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'label': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
@@ -2880,9 +2912,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_kind(self):
@@ -3091,9 +3135,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -3255,9 +3305,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_occupied(self):
params = {'occupied': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'occupied': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -4159,9 +4215,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cabled(self):
params = {'cabled': 'true'}
params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cabled': 'false'}
params = {'cabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_connected(self):

View File

@@ -1,6 +1,6 @@
from django.test import TestCase
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
from dcim.forms import *
from dcim.models import *
from utilities.testing import create_test_device
@@ -129,10 +129,11 @@ class LabelTestCase(TestCase):
"""
interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-9]',
'name': 'eth[0-9]',
'label': 'Interface[0-9]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
}
form = DeviceComponentCreateForm(interface_data)
form = InterfaceCreateForm(interface_data)
self.assertTrue(form.is_valid())
@@ -142,10 +143,11 @@ class LabelTestCase(TestCase):
"""
bad_interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-1]',
'name': 'eth[0-9]',
'label': 'Interface[0-1]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
}
form = DeviceComponentCreateForm(bad_interface_data)
form = InterfaceCreateForm(bad_interface_data)
self.assertFalse(form.is_valid())
self.assertIn('label_pattern', form.errors)
self.assertIn('label', form.errors)

View File

@@ -1082,31 +1082,28 @@ front-ports:
class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsolePortTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ConsolePortTemplate.objects.bulk_create((
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 1'),
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 2'),
ConsolePortTemplate(device_type=devicetypes[0], name='Console Port Template 3'),
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Console Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Console Port Template [4-6]',
'device_type': devicetype.pk,
'name': 'Console Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
@@ -1117,31 +1114,28 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ConsoleServerPortTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ConsoleServerPortTemplate.objects.bulk_create((
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 1'),
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 2'),
ConsoleServerPortTemplate(device_type=devicetypes[0], name='Console Server Port Template 3'),
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Console Server Port Template X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Console Server Port Template [4-6]',
'device_type': devicetype.pk,
'name': 'Console Server Port Template [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
}
@@ -1152,24 +1146,21 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerPortTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
PowerPortTemplate.objects.bulk_create((
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 2'),
PowerPortTemplate(device_type=devicetypes[0], name='Power Port Template 3'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Power Port Template X',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
@@ -1177,8 +1168,8 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Power Port Template [4-6]',
'device_type': devicetype.pk,
'name': 'Power Port Template [4-6]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
@@ -1193,6 +1184,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = PowerOutletTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1220,7 +1212,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
cls.bulk_create_data = {
'device_type': devicetype.pk,
'name_pattern': 'Power Outlet Template [4-6]',
'name': 'Power Outlet Template [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[0].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -1234,34 +1226,31 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = InterfaceTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
InterfaceTemplate.objects.bulk_create((
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 1'),
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 2'),
InterfaceTemplate(device_type=devicetypes[0], name='Interface Template 3'),
InterfaceTemplate(device_type=devicetype, name='Interface Template 1'),
InterfaceTemplate(device_type=devicetype, name='Interface Template 2'),
InterfaceTemplate(device_type=devicetype, name='Interface Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Interface Template X',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Interface Template [4-6]',
'device_type': devicetype.pk,
'name': 'Interface Template [4-6]',
# Test that a label can be applied to each generated interface templates
'label_pattern': 'Interface Template Label [3-5]',
'label': 'Interface Template Label [3-5]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
@@ -1274,6 +1263,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = FrontPortTemplate
validation_excluded_fields = ('name', 'label', 'rear_port')
@classmethod
def setUpTestData(cls):
@@ -1306,11 +1296,9 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
cls.bulk_create_data = {
'device_type': devicetype.pk,
'name_pattern': 'Front Port [4-6]',
'name': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port_set': [
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
}
cls.bulk_edit_data = {
@@ -1320,32 +1308,29 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = RearPortTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
RearPortTemplate.objects.bulk_create((
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 1'),
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 2'),
RearPortTemplate(device_type=devicetypes[0], name='Rear Port Template 3'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 1'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 2'),
RearPortTemplate(device_type=devicetype, name='Rear Port Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Rear Port Template X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Rear Port Template [4-6]',
'device_type': devicetype.pk,
'name': 'Rear Port Template [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 2,
}
@@ -1357,30 +1342,27 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = ModuleBayTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
ModuleBayTemplate.objects.bulk_create((
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'),
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'),
ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Module Bay Template X',
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Module Bay Template [4-6]',
'device_type': devicetype.pk,
'name': 'Module Bay Template [4-6]',
}
cls.bulk_edit_data = {
@@ -1390,30 +1372,27 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetypes = (
DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT),
)
DeviceType.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT)
DeviceBayTemplate.objects.bulk_create((
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 1'),
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 2'),
DeviceBayTemplate(device_type=devicetypes[0], name='Device Bay Template 3'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
))
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Device Bay Template X',
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Device Bay Template [4-6]',
'device_type': devicetype.pk,
'name': 'Device Bay Template [4-6]',
}
cls.bulk_edit_data = {
@@ -1423,6 +1402,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = InventoryItemTemplate
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1431,30 +1411,25 @@ class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
Manufacturer.objects.bulk_create(manufacturers)
devicetypes = (
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.objects.bulk_create(devicetypes)
devicetype = DeviceType.objects.create(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1')
inventory_item_templates = (
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]),
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]),
InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]),
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturers[0]),
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturers[0]),
InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturers[0]),
)
for item in inventory_item_templates:
item.save()
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_type': devicetype.pk,
'name': 'Inventory Item Template X',
'manufacturer': manufacturers[1].pk,
}
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Inventory Item Template [4-6]',
'device_type': devicetype.pk,
'name': 'Inventory Item Template [4-6]',
'manufacturer': manufacturers[1].pk,
}
@@ -1803,10 +1778,12 @@ class ModuleTestCase(
ModuleBay(device=devices[0], name='Module Bay 2'),
ModuleBay(device=devices[0], name='Module Bay 3'),
ModuleBay(device=devices[0], name='Module Bay 4'),
ModuleBay(device=devices[0], name='Module Bay 5'),
ModuleBay(device=devices[1], name='Module Bay 1'),
ModuleBay(device=devices[1], name='Module Bay 2'),
ModuleBay(device=devices[1], name='Module Bay 3'),
ModuleBay(device=devices[1], name='Module Bay 4'),
ModuleBay(device=devices[1], name='Module Bay 5'),
)
ModuleBay.objects.bulk_create(module_bays)
@@ -1820,7 +1797,7 @@ class ModuleTestCase(
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': devices[1].pk,
'device': devices[0].pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'serial': 'A',
@@ -1892,7 +1869,6 @@ class ModuleTestCase(
self.assertIsNone(interface.module)
# Create a module with adopted components
form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
form_data['module_type'] = module_type
form_data['replicate_components'] = False
form_data['adopt_components'] = True
@@ -1912,6 +1888,7 @@ class ModuleTestCase(
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1935,9 +1912,9 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Port [4-6]',
'name': 'Console Port [4-6]',
# Test that a label can be applied to each generated console ports
'label_pattern': 'Serial[3-5]',
'label': 'Serial[3-5]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': sorted([t.pk for t in tags]),
@@ -1970,6 +1947,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1993,7 +1971,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Server Port [4-6]',
'name': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': [t.pk for t in tags],
@@ -2026,6 +2004,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2051,7 +2030,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Power Port [4-6]]',
'name': 'Power Port [4-6]]',
'type': PowerPortTypeChoices.TYPE_IEC_C14,
'maximum_draw': 100,
'allocated_draw': 50,
@@ -2088,6 +2067,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2119,7 +2099,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Power Outlet [4-6]',
'name': 'Power Outlet [4-6]',
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
@@ -2153,6 +2133,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2217,7 +2198,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Interface [4-6]',
'name': 'Interface [4-6]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'bridge': interfaces[4].pk,
@@ -2277,6 +2258,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort
validation_excluded_fields = ('name', 'label', 'rear_port')
@classmethod
def setUpTestData(cls):
@@ -2312,11 +2294,9 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Front Port [4-6]',
'name': 'Front Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port_set': [
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
'rear_port': [f'{rp.pk}:1' for rp in rearports[3:6]],
'description': 'New description',
'tags': [t.pk for t in tags],
}
@@ -2348,6 +2328,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2372,7 +2353,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Rear Port [4-6]',
'name': 'Rear Port [4-6]',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
@@ -2406,6 +2387,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ModuleBay
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2428,7 +2410,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Module Bay [4-6]',
'name': 'Module Bay [4-6]',
'description': 'A module bay',
'tags': [t.pk for t in tags],
}
@@ -2447,6 +2429,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2472,7 +2455,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Device Bay [4-6]',
'name': 'Device Bay [4-6]',
'description': 'A device bay',
'tags': [t.pk for t in tags],
}
@@ -2491,6 +2474,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2525,7 +2509,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Inventory Item [4-6]',
'name': 'Inventory Item [4-6]',
'role': roles[1].pk,
'manufacturer': manufacturer.pk,
'parent': None,

View File

@@ -355,7 +355,7 @@ class SiteView(generic.ObjectView):
nonracked_devices = Device.objects.filter(
site=instance,
position__isnull=True,
rack__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
@@ -450,7 +450,7 @@ class LocationView(generic.ObjectView):
nonracked_devices = Device.objects.filter(
location=instance,
position__isnull=True,
rack__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
@@ -589,17 +589,18 @@ class RackElevationListView(generic.ObjectListView):
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Ordering
ORDERING_CHOICES = {
'name': 'Name (A-Z)',
'-name': 'Name (Z-A)',
'facility_id': 'Facility ID (A-Z)',
'-facility_id': 'Facility ID (Z-A)',
}
sort = request.GET.get('sort', "name")
sort = request.GET.get('sort', 'name')
if sort not in ORDERING_CHOICES:
sort = 'name'
racks = racks.order_by(sort)
sort_field = sort.replace("name", "_name") # Use natural ordering
racks = racks.order_by(sort_field)
# Pagination
per_page = get_paginate_count(request)
@@ -1120,9 +1121,8 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsolePortTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/component_template_create.html'
class ConsolePortTemplateEditView(generic.ObjectEditView):
@@ -1155,9 +1155,8 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPortTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/component_template_create.html'
class ConsoleServerPortTemplateEditView(generic.ObjectEditView):
@@ -1190,9 +1189,8 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerPortTemplateCreateView(generic.ComponentCreateView):
queryset = PowerPortTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/component_template_create.html'
class PowerPortTemplateEditView(generic.ObjectEditView):
@@ -1225,9 +1223,8 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
queryset = PowerOutletTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/component_template_create.html'
class PowerOutletTemplateEditView(generic.ObjectEditView):
@@ -1260,9 +1257,8 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
class InterfaceTemplateCreateView(generic.ComponentCreateView):
queryset = InterfaceTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/component_template_create.html'
class InterfaceTemplateEditView(generic.ObjectEditView):
@@ -1297,15 +1293,6 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
queryset = FrontPortTemplate.objects.all()
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
template_name = 'dcim/frontporttemplate_create.html'
def initialize_forms(self, request):
form, model_form = super().initialize_forms(request)
model_form.fields.pop('rear_port')
model_form.fields.pop('rear_port_position')
return form, model_form
class FrontPortTemplateEditView(generic.ObjectEditView):
@@ -1338,9 +1325,8 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
class RearPortTemplateCreateView(generic.ComponentCreateView):
queryset = RearPortTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
template_name = 'dcim/component_template_create.html'
class RearPortTemplateEditView(generic.ObjectEditView):
@@ -1375,8 +1361,6 @@ class ModuleBayTemplateCreateView(generic.ComponentCreateView):
queryset = ModuleBayTemplate.objects.all()
form = forms.ModuleBayTemplateCreateForm
model_form = forms.ModuleBayTemplateForm
template_name = 'dcim/modulebaytemplate_create.html'
patterned_fields = ('name', 'label', 'position')
class ModuleBayTemplateEditView(generic.ObjectEditView):
@@ -1409,9 +1393,8 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceBayTemplateCreateView(generic.ComponentCreateView):
queryset = DeviceBayTemplate.objects.all()
form = forms.ComponentTemplateCreateForm
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/component_template_create.html'
class DeviceBayTemplateEditView(generic.ObjectEditView):
@@ -1444,9 +1427,8 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
class InventoryItemTemplateCreateView(generic.ComponentCreateView):
queryset = InventoryItemTemplate.objects.all()
form = forms.ModularComponentTemplateCreateForm
form = forms.InventoryItemTemplateCreateForm
model_form = forms.InventoryItemTemplateForm
template_name = 'dcim/inventoryitemtemplate_create.html'
def alter_object(self, instance, request):
# Set component (if any)
@@ -1635,6 +1617,7 @@ class DeviceView(generic.ObjectView):
return {
'services': services,
'vc_members': vc_members,
'svg_extra': f'highlight=id:{instance.pk}'
}
@@ -1874,14 +1857,13 @@ class ConsolePortView(generic.ObjectView):
class ConsolePortCreateView(generic.ComponentCreateView):
queryset = ConsolePort.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
class ConsolePortEditView(generic.ObjectEditView):
queryset = ConsolePort.objects.all()
form = forms.ConsolePortForm
template_name = 'dcim/device_component_edit.html'
class ConsolePortDeleteView(generic.ObjectDeleteView):
@@ -1933,14 +1915,13 @@ class ConsoleServerPortView(generic.ObjectView):
class ConsoleServerPortCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPort.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
class ConsoleServerPortEditView(generic.ObjectEditView):
queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_edit.html'
class ConsoleServerPortDeleteView(generic.ObjectDeleteView):
@@ -1992,14 +1973,13 @@ class PowerPortView(generic.ObjectView):
class PowerPortCreateView(generic.ComponentCreateView):
queryset = PowerPort.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
class PowerPortEditView(generic.ObjectEditView):
queryset = PowerPort.objects.all()
form = forms.PowerPortForm
template_name = 'dcim/device_component_edit.html'
class PowerPortDeleteView(generic.ObjectDeleteView):
@@ -2051,14 +2031,13 @@ class PowerOutletView(generic.ObjectView):
class PowerOutletCreateView(generic.ComponentCreateView):
queryset = PowerOutlet.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
class PowerOutletEditView(generic.ObjectEditView):
queryset = PowerOutlet.objects.all()
form = forms.PowerOutletForm
template_name = 'dcim/device_component_edit.html'
class PowerOutletDeleteView(generic.ObjectDeleteView):
@@ -2154,42 +2133,13 @@ class InterfaceView(generic.ObjectView):
class InterfaceCreateView(generic.ComponentCreateView):
queryset = Interface.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
# template_name = 'dcim/interface_create.html'
# TODO: Figure out what to do with this
# def post(self, request):
# """
# Override inherited post() method to handle request to assign newly created
# interface objects (first object) to an IP Address object.
# """
# form = self.form(request.POST, initial=request.GET)
# new_objs = self.validate_form(request, form)
#
# if form.is_valid() and not form.errors:
# if '_addanother' in request.POST:
# return redirect(request.get_full_path())
# elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
# request.user.has_perm('ipam.add_ipaddress'):
# first_obj = new_objs[0].pk
# return redirect(
# f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
# )
# else:
# return redirect(self.get_return_url(request))
#
# return render(request, self.template_name, {
# 'obj_type': self.queryset.model._meta.verbose_name,
# 'form': form,
# 'return_url': self.get_return_url(request),
# })
class InterfaceEditView(generic.ObjectEditView):
queryset = Interface.objects.all()
form = forms.InterfaceForm
template_name = 'dcim/interface_edit.html'
class InterfaceDeleteView(generic.ObjectDeleteView):
@@ -2244,19 +2194,10 @@ class FrontPortCreateView(generic.ComponentCreateView):
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
def initialize_forms(self, request):
form, model_form = super().initialize_forms(request)
model_form.fields.pop('rear_port')
model_form.fields.pop('rear_port_position')
return form, model_form
class FrontPortEditView(generic.ObjectEditView):
queryset = FrontPort.objects.all()
form = forms.FrontPortForm
template_name = 'dcim/device_component_edit.html'
class FrontPortDeleteView(generic.ObjectDeleteView):
@@ -2308,14 +2249,13 @@ class RearPortView(generic.ObjectView):
class RearPortCreateView(generic.ComponentCreateView):
queryset = RearPort.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.RearPortCreateForm
model_form = forms.RearPortForm
class RearPortEditView(generic.ObjectEditView):
queryset = RearPort.objects.all()
form = forms.RearPortForm
template_name = 'dcim/device_component_edit.html'
class RearPortDeleteView(generic.ObjectDeleteView):
@@ -2369,13 +2309,11 @@ class ModuleBayCreateView(generic.ComponentCreateView):
queryset = ModuleBay.objects.all()
form = forms.ModuleBayCreateForm
model_form = forms.ModuleBayForm
patterned_fields = ('name', 'label', 'position')
class ModuleBayEditView(generic.ObjectEditView):
queryset = ModuleBay.objects.all()
form = forms.ModuleBayForm
template_name = 'dcim/device_component_edit.html'
class ModuleBayDeleteView(generic.ObjectDeleteView):
@@ -2423,14 +2361,13 @@ class DeviceBayView(generic.ObjectView):
class DeviceBayCreateView(generic.ComponentCreateView):
queryset = DeviceBay.objects.all()
form = forms.DeviceComponentCreateForm
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
class DeviceBayEditView(generic.ObjectEditView):
queryset = DeviceBay.objects.all()
form = forms.DeviceBayForm
template_name = 'dcim/device_component_edit.html'
class DeviceBayDeleteView(generic.ObjectDeleteView):
@@ -2552,7 +2489,6 @@ class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
template_name = 'dcim/inventoryitem_create.html'
def alter_object(self, instance, request):
# Set component (if any)
@@ -2736,7 +2672,6 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
patterned_fields = ('name', 'label', 'position')
class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):

View File

@@ -5,6 +5,7 @@ from rest_framework.serializers import ValidationError
from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
#
@@ -69,6 +70,23 @@ class CustomFieldsDataField(Field):
"values."
)
# Serialize object and multi-object values
for cf in self._get_custom_fields():
if cf.name in data and cf.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
serializer_class = get_serializer_for_model(
model=cf.object_type.model_class(),
prefix=NESTED_SERIALIZER_PREFIX
)
many = cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT
serializer = serializer_class(data=data[cf.name], many=many, context=self.parent.context)
if serializer.is_valid():
data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
else:
raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
# If updating an existing instance, start with existing custom_field_data
if self.parent.instance:
data = {**self.parent.instance.custom_field_data, **data}

View File

@@ -159,7 +159,7 @@ class ReportViewSet(ViewSet):
# Read the PK as "<module>.<report>"
if '.' not in pk:
raise Http404
module_name, report_name = pk.split('.', 1)
module_name, report_name = pk.split('.', maxsplit=1)
# Raise a 404 on an invalid Report module/name
report = get_report(module_name, report_name)
@@ -183,8 +183,8 @@ class ReportViewSet(ViewSet):
}
# Iterate through all available Reports.
for module_name, reports in get_reports():
for report in reports:
for module_name, reports in get_reports().items():
for report in reports.values():
# Attach the relevant JobResult (if any) to each Report.
report.result = results.get(report.full_name, None)
@@ -257,7 +257,7 @@ class ScriptViewSet(ViewSet):
lookup_value_regex = '[^/]+' # Allow dots
def _get_script(self, pk):
module_name, script_name = pk.split('.')
module_name, script_name = pk.split('.', maxsplit=1)
script = get_script(module_name, script_name)
if script is None:
raise Http404

View File

@@ -1,10 +1,6 @@
from contextlib import contextmanager
from django.db.models.signals import m2m_changed, pre_delete, post_save
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
from netbox import thread_locals
from netbox.request_context import set_request
from netbox.context import current_request, webhooks_queue
from .webhooks import flush_webhooks
@@ -16,27 +12,14 @@ def change_logging(request):
:param request: WSGIRequest object with a unique `id` set
"""
set_request(request)
thread_locals.webhook_queue = []
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
current_request.set(request)
webhooks_queue.set([])
yield
# Disconnect change logging signals. This is necessary to avoid recording any errant
# changes during test cleanup.
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
# Flush queued webhooks to RQ
flush_webhooks(thread_locals.webhook_queue)
del thread_locals.webhook_queue
flush_webhooks(webhooks_queue.get())
# Clear the request from thread-local storage
set_request(None)
# Clear context vars
current_request.set(None)
webhooks_queue.set([])

View File

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from extras.choices import CustomFieldTypeChoices
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
from extras.models import *
from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
@@ -38,6 +38,10 @@ class CustomFieldCSVForm(CSVModelForm):
required=False,
help_text='Comma-separated list of field choices'
)
ui_visibility = CSVChoiceField(
choices=CustomFieldVisibilityChoices,
help_text='How the custom field is displayed in the user interface'
)
class Meta:
model = CustomField

View File

@@ -34,7 +34,9 @@ class CustomFieldsMixin:
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type)
return CustomField.objects.filter(content_types=content_type).exclude(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
)
def _get_form_field(self, customfield):
return customfield.to_form_field()
@@ -50,13 +52,6 @@ class CustomFieldsMixin:
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
self.fields[field_name].disabled = True
if self.fields[field_name].help_text:
self.fields[field_name].help_text += '<br />'
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
'Field is set to read-only.'
# Annotate the field in the list of CustomField form fields
self.custom_fields[field_name] = customfield
if customfield.group_name not in self.custom_field_groups:

View File

@@ -21,8 +21,8 @@ class Command(BaseCommand):
reports = get_reports()
# Run reports
for module_name, report_list in reports:
for report in report_list:
for module_name, report_list in reports.items():
for report in report_list.values():
if module_name in options['reports'] or report.full_name in options['reports']:
# Run the report and create a new JobResult

View File

@@ -14,7 +14,7 @@ from django.utils.safestring import mark_safe
from extras.choices import *
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
from utilities import filters
from utilities.forms import (
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
@@ -41,7 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type)
class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
content_types = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
@@ -143,8 +143,14 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
verbose_name='UI visibility',
help_text='Specifies the visibility of custom field in the UI'
)
objects = CustomFieldManager()
clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default',
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility',
)
class Meta:
ordering = ['group_name', 'weight', 'name']
@@ -291,12 +297,13 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
return model.objects.filter(pk__in=value)
return value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
@@ -392,6 +399,12 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
if self.description:
field.help_text = escape(self.description)
# Annotate read-only fields
if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
field.disabled = True
prepend = '<br />' if field.help_text else ''
field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
return field
def to_filter(self, lookup_expr=None):

View File

@@ -21,7 +21,7 @@ from extras.conditions import ConditionSet
from extras.utils import FeatureQuery, image_upload
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
)
from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2
@@ -187,7 +187,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
return render_jinja2(self.payload_url, context)
class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
@@ -230,6 +230,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
help_text="Force link to open in a new window"
)
clone_fields = (
'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
)
class Meta:
ordering = ['group_name', 'weight', 'name']
@@ -459,6 +463,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin
def get_absolute_url(self):
return reverse('extras:journalentry', args=[self.pk])
def clean(self):
super().clean()
# Prevent the creation of journal entries on unsupported models
permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
if self.assigned_object_type not in permitted_types:
raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
def get_kind_color(self):
return JournalEntryKindChoices.colors.get(self.kind)

View File

@@ -26,20 +26,18 @@ def get_report(module_name, report_name):
"""
Return a specific report from within a module.
"""
file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
reports = get_reports()
module = reports.get(module_name)
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except FileNotFoundError:
if module is None:
return None
report = getattr(module, report_name, None)
report = module.get(report_name)
if report is None:
return None
return report()
return report
def get_reports():
@@ -52,7 +50,7 @@ def get_reports():
...
]
"""
module_list = []
module_list = {}
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
@@ -61,7 +59,16 @@ def get_reports():
report_order = getattr(module, "report_order", ())
ordered_reports = [cls() for cls in report_order if is_report(cls)]
unordered_reports = [cls() for _, cls in inspect.getmembers(module, is_report) if cls not in report_order]
module_list.append((module_name, [*ordered_reports, *unordered_reports]))
module_reports = {}
for cls in [*ordered_reports, *unordered_reports]:
# For reports in submodules use the full import path w/o the root module as the name
report_name = cls.full_name.split(".", maxsplit=1)[1]
module_reports[report_name] = cls
if module_reports:
module_list[module_name] = module_reports
return module_list

View File

@@ -299,6 +299,10 @@ class BaseScript:
def module(cls):
return cls.__module__
@classmethod
def root_module(cls):
return cls.__module__.split(".")[0]
@classproperty
def job_timeout(self):
return getattr(self.Meta, 'job_timeout', None)
@@ -514,7 +518,9 @@ def get_scripts(use_names=False):
ordered_scripts = [cls for cls in script_order if is_script(cls)]
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
for cls in [*ordered_scripts, *unordered_scripts]:
module_scripts[cls.__name__] = cls
# For scripts in submodules use the full import path w/o the root module as the name
script_name = cls.full_name.split(".", maxsplit=1)[1]
module_scripts[script_name] = cls
if module_scripts:
scripts[module_name] = module_scripts

View File

@@ -7,9 +7,8 @@ from django.dispatch import receiver, Signal
from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.validators import CustomValidator
from netbox import thread_locals
from netbox.config import get_config
from netbox.request_context import get_request
from netbox.context import current_request, webhooks_queue
from netbox.signals import post_clean
from .choices import ObjectChangeActionChoices
from .models import ConfigRevision, CustomField, ObjectChange
@@ -23,22 +22,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
clear_webhooks = Signal()
def is_same_object(instance, webhook_data, request_id):
"""
Compare the given instance to the most recent queued webhook object, returning True
if they match. This check is used to avoid creating duplicate webhook entries.
"""
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request_id == webhook_data['request_id']
)
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
m2m_changed = False
if not hasattr(instance, 'to_objectchange'):
return
request = get_request()
m2m_changed = False
def is_same_object(instance, webhook_data):
return (
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
instance.pk == webhook_data['object_id'] and
request.id == webhook_data['request_id']
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Determine the type of change being made
if kwargs.get('created'):
@@ -69,13 +78,14 @@ def handle_changed_object(sender, instance, **kwargs):
objectchange.save()
# If this is an M2M change, update the previously queued webhook (from post_save)
webhook_queue = thread_locals.webhook_queue
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
queue = webhooks_queue.get()
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
queue[-1]['data'] = serialize_for_webhook(instance)
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
else:
enqueue_object(webhook_queue, instance, request.user, request.id, action)
enqueue_object(queue, instance, request.user, request.id, action)
webhooks_queue.set(queue)
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:
@@ -84,39 +94,42 @@ def handle_changed_object(sender, instance, **kwargs):
model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
if not hasattr(instance, 'to_objectchange'):
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
request = get_request()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
webhook_queue = thread_locals.webhook_queue
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
queue = webhooks_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
webhooks_queue.set(queue)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_webhooks)
def clear_webhook_queue(sender, **kwargs):
"""
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('webhooks')
webhook_queue = thread_locals.webhook_queue
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
webhook_queue.clear()
logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
webhooks_queue.set([])
#

View File

@@ -803,6 +803,57 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field'])
self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
def test_specify_related_object_by_attr(self):
site1 = Site.objects.get(name='Site 1')
vlans = VLAN.objects.all()[:3]
url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk})
self.add_permissions('dcim.change_site')
# Set related objects by PK
data = {
'custom_fields': {
'object_field': vlans[0].pk,
'multiobject_field': [vlans[1].pk, vlans[2].pk],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
# Set related objects by name
data = {
'custom_fields': {
'object_field': {
'name': vlans[0].name,
},
'multiobject_field': [
{
'name': vlans[1].name
},
{
'name': vlans[2].name
},
],
},
}
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(
response.data['custom_fields']['object_field']['id'],
vlans[0].pk
)
self.assertListEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
[vlans[1].pk, vlans[2].pk]
)
def test_minimum_maximum_values_validation(self):
site2 = Site.objects.get(name='Site 2')
url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk})

View File

@@ -1,4 +1,4 @@
from django.urls import path
from django.urls import path, re_path
from extras import models, views
from netbox.views.generic import ObjectChangeLogView
@@ -100,12 +100,12 @@ urlpatterns = [
# Reports
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
re_path(r'^reports/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ReportView.as_view(), name='report'),
# Scripts
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
]

View File

@@ -441,6 +441,12 @@ class ImageAttachmentEditView(generic.ObjectEditView):
def get_return_url(self, request, obj=None):
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
def get_extra_addanother_params(self, request):
return {
'content_type': request.GET.get('content_type'),
'object_id': request.GET.get('object_id'),
}
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
queryset = ImageAttachment.objects.all()
@@ -528,9 +534,10 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
}
ret = []
for module, report_list in reports:
for module, report_list in reports.items():
module_reports = []
for report in report_list:
for report in report_list.values():
report.result = results.get(report.full_name, None)
module_reports.append(report)
ret.append((module, module_reports))
@@ -607,7 +614,7 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
# Retrieve the Report and attach the JobResult to it
module, report_name = result.name.split('.')
module, report_name = result.name.split('.', maxsplit=1)
report = get_report(module, report_name)
report.result = result

View File

@@ -175,6 +175,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
queryset=ContentType.objects.filter(
model__in=VLANGROUP_SCOPE_TYPES
),
allow_null=True,
required=False,
default=None
)

View File

@@ -112,6 +112,18 @@ class IPAddressViewSet(NetBoxModelViewSet):
serializer_class = serializers.IPAddressSerializer
filterset_class = filtersets.IPAddressFilterSet
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def update(self, request, *args, **kwargs):
return super().update(request, *args, **kwargs)
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def destroy(self, request, *args, **kwargs):
return super().destroy(request, *args, **kwargs)
class FHRPGroupViewSet(NetBoxModelViewSet):
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')

View File

@@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service
tag = TagFilterField(model)
class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):

View File

@@ -88,6 +88,12 @@ class RouteTargetForm(TenancyForm, NetBoxModelForm):
class RIRForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('RIR', (
'name', 'slug', 'is_private', 'description', 'tags',
)),
)
class Meta:
model = RIR
fields = [
@@ -164,6 +170,12 @@ class ASNForm(TenancyForm, NetBoxModelForm):
class RoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Role', (
'name', 'slug', 'weight', 'description', 'tags',
)),
)
class Meta:
model = Role
fields = [
@@ -417,7 +429,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
initial['nat_rack'] = nat_inside_parent.device.rack.pk
initial['nat_device'] = nat_inside_parent.device.pk
elif type(nat_inside_parent) is VMInterface:
initial['nat_cluster'] = nat_inside_parent.virtual_machine.cluster.pk
if cluster := nat_inside_parent.virtual_machine.cluster:
initial['nat_cluster'] = cluster.pk
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
kwargs['initial'] = initial
@@ -537,9 +550,15 @@ class FHRPGroupForm(NetBoxModelForm):
fields = (
'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
)
widgets = {
'protocol': StaticSelect(),
'auth_type': StaticSelect(),
'ip_status': StaticSelect(),
}
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
user = getattr(instance, '_user', None) # Set under FHRPGroupEditView.alter_object()
# Check if we need to create a new IPAddress for the group
if self.cleaned_data.get('ip_address'):
@@ -553,7 +572,7 @@ class FHRPGroupForm(NetBoxModelForm):
ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions
if not IPAddress.objects.filter(pk=ipaddress.pk).first():
if not IPAddress.objects.restrict(user, 'add').filter(pk=ipaddress.pk).first():
raise PermissionsViolation()
return instance
@@ -784,6 +803,12 @@ class ServiceTemplateForm(NetBoxModelForm):
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
)
fieldsets = (
('Service Template', (
'name', 'protocol', 'ports', 'description', 'tags',
)),
)
class Meta:
model = ServiceTemplate
fields = ('name', 'protocol', 'ports', 'description', 'tags')
@@ -854,6 +879,7 @@ class ServiceCreateForm(ServiceForm):
del self.fields[field].widget.attrs['required']
def clean(self):
super().clean()
if self.cleaned_data['service_template']:
# Create a new Service from the specified template
service_template = self.cleaned_data['service_template']

View File

@@ -8,8 +8,6 @@ from django.urls import reverse
from django.utils.functional import cached_property
from dcim.fields import ASNField
from dcim.models import Device
from netbox.models import OrganizationalModel, NetBoxModel
from ipam.choices import *
from ipam.constants import *
from ipam.fields import IPNetworkField, IPAddressField
@@ -17,8 +15,7 @@ from ipam.managers import IPAddressManager
from ipam.querysets import PrefixQuerySet
from ipam.validators import DNSValidator
from netbox.config import get_config
from virtualization.models import VirtualMachine
from netbox.models import OrganizationalModel, NetBoxModel
__all__ = (
'Aggregate',
@@ -912,18 +909,6 @@ class IPAddress(NetBoxModel):
)
})
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk:
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if parent and getattr(self.assigned_object, attr, None) != parent:
# Check for a NAT relationship
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
raise ValidationError({
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
f"not assigned to it!"
})
# Validate IP status selection
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
raise ValidationError({

View File

@@ -92,6 +92,8 @@ class Service(ServiceBase, NetBoxModel):
verbose_name='IP addresses'
)
clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ]
class Meta:
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique

View File

@@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
# Find all relevant VLANGroups
q = Q()
if vm.cluster.site:
if vm.cluster.site.region:
site = vm.site or vm.cluster.site
if vm.cluster:
# Add VLANGroups scoped to the assigned cluster (or its group)
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
scope_id=vm.cluster_id
)
if vm.cluster.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True)
)
if vm.cluster.site.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True)
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
scope_id=vm.cluster.group_id
)
if site:
# Add VLANGroups scoped to the assigned site (or its group or region)
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
scope_id=vm.cluster.site_id
scope_id=site.pk
)
if vm.cluster.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
scope_id=vm.cluster.group_id
)
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
scope_id=vm.cluster_id
)
if site.region:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
scope_id__in=site.region.get_ancestors(include_self=True)
)
if site.group:
q |= Q(
scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
scope_id__in=site.group.get_ancestors(include_self=True)
)
vlan_groups = VLANGroup.objects.filter(q)
# Return all applicable VLANs
@@ -113,7 +117,7 @@ class VLANQuerySet(RestrictedQuerySet):
Q(group__scope_id__isnull=True, site__isnull=True) | # Global group VLANs
Q(group__isnull=True, site__isnull=True) # Global VLANs
)
if vm.cluster.site:
q |= Q(site=vm.cluster.site)
if site:
q |= Q(site=site)
return self.filter(q)

View File

@@ -21,6 +21,14 @@ __all__ = (
AVAILABLE_LABEL = mark_safe('<span class="badge bg-success">Available</span>')
PREFIX_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.prefix }}</a>
{% else %}
<a href="{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}">{{ record.prefix }}</a>
{% endif %}
"""
PREFIX_LINK_WITH_DEPTH = """
{% load helpers %}
{% if record.depth %}
<div class="record-depth">
@@ -29,8 +37,7 @@ PREFIX_LINK = """
{% endfor %}
</div>
{% endif %}
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
"""
""" + PREFIX_LINK
IPADDRESS_LINK = """
{% if record.pk %}
@@ -216,14 +223,15 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
class PrefixTable(TenancyColumnsMixin, NetBoxTable):
prefix = columns.TemplateColumn(
template_code=PREFIX_LINK,
template_code=PREFIX_LINK_WITH_DEPTH,
export_raw=True,
attrs={'td': {'class': 'text-nowrap'}}
)
prefix_flat = tables.Column(
prefix_flat = columns.TemplateColumn(
accessor=Accessor('prefix'),
linkify=True,
verbose_name='Prefix (Flat)',
template_code=PREFIX_LINK,
export_raw=True,
verbose_name='Prefix (Flat)'
)
depth = tables.Column(
accessor=Accessor('_depth'),
@@ -367,7 +375,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
)
assigned = columns.BooleanColumn(
accessor='assigned_object_id',
linkify=True,
linkify=lambda record: record.assigned_object.get_absolute_url(),
verbose_name='Assigned'
)
tags = columns.TagColumn(

View File

@@ -930,6 +930,12 @@ class FHRPGroupEditView(generic.ObjectEditView):
return return_url
def alter_object(self, obj, request, url_args, url_kwargs):
# Workaround to solve #10719. Capture the current user on the FHRPGroup instance so that
# we can evaluate permissions during the creation of a new IPAddress within the form.
obj._user = request.user
return obj
class FHRPGroupDeleteView(generic.ObjectDeleteView):
queryset = FHRPGroup.objects.all()

View File

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

View File

@@ -58,22 +58,24 @@ class TokenAuthentication(authentication.TokenAuthentication):
if token.is_expired:
raise exceptions.AuthenticationFailed("Token expired")
if not token.user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
user = token.user
# When LDAP authentication is active try to load user data from LDAP directory
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
from netbox.authentication import LDAPBackend
ldap_backend = LDAPBackend()
# Load from LDAP if FIND_GROUP_PERMS is active
if ldap_backend.settings.FIND_GROUP_PERMS:
user = ldap_backend.populate_user(token.user.username)
# Always query LDAP when user is not active, otherwise it is never activated again
if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
ldap_user = ldap_backend.populate_user(token.user.username)
# If the user is found in the LDAP directory use it, if not fallback to the local user
if user:
return user, token
if ldap_user:
user = ldap_user
return token.user, token
if not user.is_active:
raise exceptions.AuthenticationFailed("User inactive")
return user, token
class TokenPermissions(DjangoObjectPermissions):

View File

@@ -137,9 +137,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
)
def list(self, request, *args, **kwargs):
"""
Overrides ListModelMixin to allow processing ExportTemplates.
"""
# Overrides ListModelMixin to allow processing ExportTemplates.
if 'export' in request.GET:
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])

View File

@@ -108,6 +108,5 @@ class ObjectValidationMixin:
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
if conforming_count != len(instance):
raise ObjectDoesNotExist
else:
# Check that the instance is matched by the view's queryset
self.queryset.get(pk=instance.pk)
elif not self.queryset.filter(pk=instance.pk).exists():
raise ObjectDoesNotExist

View File

@@ -24,6 +24,7 @@ AUTH_BACKEND_ATTRS = {
'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
'bitbucket': ('BitBucket', 'bitbucket'),
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
'digitalocean': ('DigitalOcean', 'digital-ocean'),
@@ -351,6 +352,14 @@ class LDAPBackend:
if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
# Optionally set CA cert directory
if ca_cert_dir := getattr(ldap_config, 'LDAP_CA_CERT_DIR', None):
ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, ca_cert_dir)
# Optionally set CA cert file
if ca_cert_file := getattr(ldap_config, 'LDAP_CA_CERT_FILE', None):
ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, ca_cert_file)
return obj

10
netbox/netbox/context.py Normal file
View File

@@ -0,0 +1,10 @@
from contextvars import ContextVar
__all__ = (
'current_request',
'webhooks_queue',
)
current_request = ContextVar('current_request', default=None)
webhooks_queue = ContextVar('webhooks_queue')

View File

@@ -80,6 +80,13 @@ class BaseFilterSet(django_filters.FilterSet):
},
})
def __init__(self, *args, **kwargs):
# bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
# however FilterSet Factory is setup before this which creates the
# initial filters. This recreates the filters so Empty is picked up correctly.
self.base_filters = self.__class__.get_filters()
super().__init__(*args, **kwargs)
@staticmethod
def _get_filter_lookup_dict(existing_filter):
# Choose the lookup expression map based on the filter type

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
from extras.forms.customfields import CustomFieldsMixin
from extras.models import CustomField, Tag
from utilities.forms import BootstrapMixin, CSVModelForm
@@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
"""
tags = None # Temporary fix in lieu of tag import support (see #9158)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).filter(
ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
)
def _get_form_field(self, customfield):
return customfield.to_form_field(for_csv_import=True)
@@ -125,10 +130,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
return super()._get_custom_fields(content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)
return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)

View File

@@ -2,7 +2,6 @@ from django.core.validators import ValidationError
from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
from extras.utils import is_taggable
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from netbox.models.features import *
@@ -21,7 +20,6 @@ class NetBoxFeatureSet(
CustomLinksMixin,
CustomValidationMixin,
ExportTemplatesMixin,
JournalingMixin,
TagsMixin,
WebhooksMixin
):
@@ -32,7 +30,7 @@ class NetBoxFeatureSet(
def get_prerequisite_models(cls):
"""
Return a list of model types that are required to create this model or empty list if none. This is used for
showing prequisite warnings in the UI on the list and detail views.
showing prerequisite warnings in the UI on the list and detail views.
"""
return []
@@ -52,7 +50,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
abstract = True
class NetBoxModel(NetBoxFeatureSet, models.Model):
class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model):
"""
Primary models represent real objects within the infrastructure being modeled.
"""
@@ -61,25 +59,6 @@ class NetBoxModel(NetBoxFeatureSet, models.Model):
class Meta:
abstract = True
def clone(self):
"""
Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
populating an object creation form in the UI.
"""
attrs = {}
for field_name in getattr(self, 'clone_fields', []):
field = self._meta.get_field(field_name)
field_value = field.value_from_object(self)
if field_value not in (None, ''):
attrs[field_name] = field_value
# Include tags (if applicable)
if is_taggable(self):
attrs['tags'] = [tag.pk for tag in self.tags.all()]
return attrs
class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
"""

View File

@@ -10,12 +10,13 @@ from django.db import models
from taggit.managers import TaggableManager
from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices
from extras.utils import register_features
from extras.utils import is_taggable, register_features
from netbox.signals import post_clean
from utilities.utils import serialize_object
__all__ = (
'ChangeLoggingMixin',
'CloningMixin',
'CustomFieldsMixin',
'CustomLinksMixin',
'CustomValidationMixin',
@@ -82,6 +83,33 @@ class ChangeLoggingMixin(models.Model):
return objectchange
class CloningMixin(models.Model):
"""
Provides the clone() method used to prepare a copy of existing objects.
"""
class Meta:
abstract = True
def clone(self):
"""
Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre-
populating an object creation form in the UI.
"""
attrs = {}
for field_name in getattr(self, 'clone_fields', []):
field = self._meta.get_field(field_name)
field_value = field.value_from_object(self)
if field_value not in (None, ''):
attrs[field_name] = field_value
# Include tags (if applicable)
if is_taggable(self):
attrs['tags'] = [tag.pk for tag in self.tags.all()]
return attrs
class CustomFieldsMixin(models.Model):
"""
Enables support for custom fields.

View File

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

View File

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.3.2'
VERSION = '3.3.9'
# Hostname
HOSTNAME = platform.node()
@@ -81,6 +81,7 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
if BASE_PATH:
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
@@ -404,6 +405,7 @@ STATIC_URL = f'/{BASE_PATH}static/'
STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'project-static', 'dist'),
os.path.join(BASE_DIR, 'project-static', 'img'),
os.path.join(BASE_DIR, 'project-static', 'js'),
('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs
)
@@ -443,6 +445,10 @@ EXEMPT_PATHS = (
f'/{BASE_PATH}metrics',
)
SERIALIZATION_MODULES = {
'json': 'utilities.serializers.json',
}
#
# Sentry
@@ -498,7 +504,7 @@ for param in dir(configuration):
# Force usage of PostgreSQL's JSONB field for extra data
SOCIAL_AUTH_JSONFIELD_ENABLED = True
SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
#
# Django Prometheus

View File

@@ -7,6 +7,7 @@ from django.contrib.auth.models import AnonymousUser
from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.encoding import escape_uri_path
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
@@ -210,7 +211,7 @@ class ActionsColumn(tables.Column):
model = table.Meta.model
request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={request.path}' if request else ''
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
html = ''
# Compile actions menu
@@ -424,6 +425,12 @@ class CustomFieldColumn(tables.Column):
kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}')
if 'verbose_name' not in kwargs:
kwargs['verbose_name'] = customfield.label or customfield.name
# We can't logically sort on FK values
if customfield.type in (
CustomFieldTypeChoices.TYPE_OBJECT,
CustomFieldTypeChoices.TYPE_MULTIOBJECT
):
kwargs['orderable'] = False
super().__init__(*args, **kwargs)

View File

@@ -1,5 +1,6 @@
import platform
import sys
from collections import namedtuple
from django.conf import settings
from django.core.cache import cache
@@ -8,6 +9,7 @@ from django.shortcuts import redirect, render
from django.template import loader
from django.template.exceptions import TemplateDoesNotExist
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View
@@ -24,100 +26,90 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
from netbox.constants import SEARCH_MAX_RESULTS
from netbox.forms import SearchForm
from netbox.search import SEARCH_TYPES
from tenancy.models import Tenant
from tenancy.models import Contact, Tenant
from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink
Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count'))
class HomeView(View):
template_name = 'home.html'
def get(self, request):
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
return redirect("login")
return redirect('login')
connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
console_connections = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__is_complete=True
)
connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
).count
power_connections = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__is_complete=True
)
connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
).count
interface_connections = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter(
_path__is_complete=True
)
).count
def get_count_queryset(model):
return model.objects.restrict(request.user, 'view').count
def build_stats():
org = (
("dcim.view_site", "Sites", Site.objects.restrict(request.user, 'view').count),
("tenancy.view_tenant", "Tenants", Tenant.objects.restrict(request.user, 'view').count),
Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
)
dcim = (
("dcim.view_rack", "Racks", Rack.objects.restrict(request.user, 'view').count),
("dcim.view_devicetype", "Device Types", DeviceType.objects.restrict(request.user, 'view').count),
("dcim.view_device", "Devices", Device.objects.restrict(request.user, 'view').count),
Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
)
ipam = (
("ipam.view_vrf", "VRFs", VRF.objects.restrict(request.user, 'view').count),
("ipam.view_aggregate", "Aggregates", Aggregate.objects.restrict(request.user, 'view').count),
("ipam.view_prefix", "Prefixes", Prefix.objects.restrict(request.user, 'view').count),
("ipam.view_iprange", "IP Ranges", IPRange.objects.restrict(request.user, 'view').count),
("ipam.view_ipaddress", "IP Addresses", IPAddress.objects.restrict(request.user, 'view').count),
("ipam.view_vlan", "VLANs", VLAN.objects.restrict(request.user, 'view').count)
Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
)
circuits = (
("circuits.view_provider", "Providers", Provider.objects.restrict(request.user, 'view').count),
("circuits.view_circuit", "Circuits", Circuit.objects.restrict(request.user, 'view').count),
Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
)
virtualization = (
("virtualization.view_cluster", "Clusters", Cluster.objects.restrict(request.user, 'view').count),
("virtualization.view_virtualmachine", "Virtual Machines", VirtualMachine.objects.restrict(request.user, 'view').count),
Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
get_count_queryset(Cluster)),
Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
get_count_queryset(VirtualMachine)),
)
connections = (
("dcim.view_cable", "Cables", Cable.objects.restrict(request.user, 'view').count),
("dcim.view_consoleport", "Console", connected_consoleports.count),
("dcim.view_interface", "Interfaces", connected_interfaces.count),
("dcim.view_powerport", "Power Connections", connected_powerports.count),
Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
)
power = (
("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
)
wireless = (
("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
get_count_queryset(WirelessLAN)),
Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
get_count_queryset(WirelessLink)),
)
sections = (
("Organization", org, "domain"),
("IPAM", ipam, "counter"),
("Virtualization", virtualization, "monitor"),
("Inventory", dcim, "server"),
("Circuits", circuits, "transit-connection-variant"),
("Connections", connections, "cable-data"),
("Power", power, "flash"),
("Wireless", wireless, "wifi"),
stats = (
(_('Organization'), org, 'domain'),
(_('IPAM'), ipam, 'counter'),
(_('Virtualization'), virtualization, 'monitor'),
(_('Inventory'), dcim, 'server'),
(_('Circuits'), circuits, 'transit-connection-variant'),
(_('Connections'), connections, 'cable-data'),
(_('Power'), power, 'flash'),
(_('Wireless'), wireless, 'wifi'),
)
stats = []
for section_label, section_items, icon_class in sections:
items = []
for perm, item_label, get_count in section_items:
app, scope = perm.split(".")
url = ":".join((app, scope.replace("view_", "") + "_list"))
item = {
"label": item_label,
"count": None,
"url": url,
"disabled": True,
"icon": icon_class,
}
if request.user.has_perm(perm):
item["count"] = get_count()
item["disabled"] = False
items.append(item)
stats.append((section_label, items, icon_class))
return stats
# Compile changelog table

View File

@@ -774,7 +774,6 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
model_form = None
filterset = None
table = None
patterned_fields = ('name', 'label')
def get_required_permission(self):
return f'dcim.add_{self.queryset.model._meta.model_name}'
@@ -804,23 +803,25 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
new_components = []
data = deepcopy(form.cleaned_data)
replication_data = {
field: data.pop(field) for field in form.replication_fields
}
try:
with transaction.atomic():
for obj in data['pk']:
pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
pattern_count = len(replication_data[form.replication_fields[0]])
for i in range(pattern_count):
component_data = {
self.parent_field: obj.pk
}
for field_name in self.patterned_fields:
if data.get(f'{field_name}_pattern'):
component_data[field_name] = data[f'{field_name}_pattern'][i]
component_data.update(data)
for field, values in replication_data.items():
if values:
component_data[field] = values[i]
component_form = self.model_form(component_data)
if component_form.is_valid():
instance = component_form.save()
@@ -829,7 +830,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
else:
for field, errors in component_form.errors.as_data().items():
for e in errors:
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
form.add_error(field, '{}: {}'.format(obj, ', '.join(e)))
# Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):

View File

@@ -125,9 +125,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model)
has_bulk_actions = any([a.startswith('bulk_') for a in actions])
table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, bool(actions))
table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML
if is_htmx(request):
@@ -173,7 +174,7 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView):
obj = model_form.save()
# Enforce object-level permissions
if not self.queryset.filter(pk=obj.pk).first():
if not self.queryset.filter(pk=obj.pk).exists():
raise PermissionsViolation()
# Iterate through the related object forms (if any), validating and saving each instance.
@@ -390,7 +391,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
obj = form.save()
# Check that the new object conforms with any assigned object-level permissions
if not self.queryset.filter(pk=obj.pk).first():
if not self.queryset.filter(pk=obj.pk).exists():
raise PermissionsViolation()
msg = '{} {}'.format(
@@ -538,10 +539,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
"""
template_name = 'dcim/component_create.html'
template_name = 'generic/object_edit.html'
form = None
model_form = None
patterned_fields = ('name', 'label')
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
@@ -549,44 +549,38 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
def alter_object(self, instance, request):
return instance
def initialize_forms(self, request):
def initialize_form(self, request):
data = request.POST if request.method == 'POST' else None
initial_data = normalize_querydict(request.GET)
form = self.form(data=data, initial=request.GET)
model_form = self.model_form(data=data, initial=initial_data)
form = self.form(data=data, initial=initial_data)
# These fields will be set from the pattern values
for field_name in self.patterned_fields:
model_form.fields[field_name].widget = HiddenInput()
return form, model_form
return form
def get(self, request):
form, model_form = self.initialize_forms(request)
form = self.initialize_form(request)
instance = self.alter_object(self.queryset.model(), request)
return render(request, self.template_name, {
'object': instance,
'replication_form': form,
'form': model_form,
'form': form,
'return_url': self.get_return_url(request),
})
def post(self, request):
logger = logging.getLogger('netbox.views.ComponentCreateView')
form, model_form = self.initialize_forms(request)
form = self.initialize_form(request)
instance = self.alter_object(self.queryset.model(), request)
if form.is_valid():
new_components = []
data = deepcopy(request.POST)
pattern_count = len(form.cleaned_data[f'{self.patterned_fields[0]}_pattern'])
pattern_count = len(form.cleaned_data[self.form.replication_fields[0]])
for i in range(pattern_count):
for field_name in self.patterned_fields:
if form.cleaned_data.get(f'{field_name}_pattern'):
data[field_name] = form.cleaned_data[f'{field_name}_pattern'][i]
for field_name in self.form.replication_fields:
if form.cleaned_data.get(field_name):
data[field_name] = form.cleaned_data[field_name][i]
if hasattr(form, 'get_iterative_data'):
data.update(form.get_iterative_data(i))
@@ -626,7 +620,6 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
return render(request, self.template_name, {
'object': instance,
'replication_form': form,
'form': model_form,
'form': form,
'return_url': self.get_return_url(request),
})

View File

@@ -31,8 +31,7 @@
}
},
"rules": {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-unused-vars-experimental": "error",
"@typescript-eslint/no-unused-vars": "error",
"no-unused-vars": "off",
"no-inner-declarations": "off",
"comma-dangle": ["error", "always-multiline"],

View File

@@ -1 +1 @@
:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,Liberation Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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