Compare commits

...

147 Commits

Author SHA1 Message Date
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
Jeremy Stretch
41d653738a Merge pull request #10254 from netbox-community/develop
Release v3.3.2
2022-09-02 15:33:54 -04:00
jeremystretch
1955497dbe Release v3.3.2 2022-09-02 15:20:14 -04:00
Jeremy Stretch
572803d7ac Merge pull request #10253 from netbox-community/9823-fix_numericarrayfield_validation_error
Fixes #9823 - Make validation error from NumericArrayField more verbose.
2022-09-02 15:18:42 -04:00
Daniel Sheppard
25a474f9f7 Fix PEP8 errors 2022-09-02 14:05:38 -05:00
Daniel Sheppard
c42f7ab6d3 Fixes #9823 - Make validation error from NumericArrayField more verbose. 2022-09-02 13:48:15 -05:00
Jeremy Stretch
a7717b432e Merge pull request #10252 from netbox-community/10034-l2vpn-column
Fixes #10034: Add L2VPN column to interface and VLAN tables
2022-09-02 14:39:59 -04:00
jeremystretch
2acfda3dc5 Fixes #10034: Add L2VPN column to interface and VLAN tables 2022-09-02 14:26:35 -04:00
jeremystretch
569525fb68 Changelog for #9481, #10231, #10233 2022-09-02 08:56:09 -04:00
Jeremy Stretch
15353b7513 Merge pull request #10233 from netbox-community/9665-rack-elevation-sorting
#9665 Rack Elevation Sorting Enhancements
2022-09-02 08:53:16 -04:00
Jeremy Stretch
4737336b85 Merge pull request #10239 from amhn/Add_missing_serializer_annotation
Closes #10231: Add missing serializer annotations
2022-09-02 08:27:26 -04:00
Jeremy Stretch
d85b1c775f Merge pull request #10246 from netbox-community/9481-child-parent-location
#9481 update child location whem move parent
2022-09-02 08:25:34 -04:00
Arthur
2ce1a96468 #9481 update child location whem move parent 2022-09-01 15:44:41 -07:00
Arthur
239b8d2e7c #9665 review changes 2022-09-01 11:15:23 -07:00
Arthur
43b3ce9ed1 #9665 review changes 2022-09-01 11:12:01 -07:00
Arthur
80d5a966db Merge branch 'develop' into 9665-rack-elevation-sorting 2022-09-01 09:38:38 -07:00
Andreas Nieß
b11fa53519 Closes #10231: Add missing serializer annotations 2022-09-01 17:18:09 +02:00
jeremystretch
ce4d00dc21 Fixes #10184: Fix vertical alignment when displaying object attributes with buttons 2022-09-01 09:53:48 -04:00
Jeremy Stretch
bb269affe2 Merge pull request #10235 from netbox-community/10220-vm-primary-ips
Fixes #10220: Validate IP version when assigning primary IPs to a VM
2022-09-01 09:47:50 -04:00
jeremystretch
d818c250b0 Fixes #10220: Validate IP version when assigning primary IPs to a virtual machine 2022-09-01 09:31:42 -04:00
Arthur
3d4d880110 #9665 Rack Elevation Sorting Enhancements 2022-08-31 14:42:59 -07:00
jeremystretch
899b61264f Fixes #9328: Hide available IPs when non-default ordering is applied 2022-08-31 16:55:03 -04:00
jeremystretch
4dc059fba3 Changelog for #9477, #10195 2022-08-31 16:44:43 -04:00
Jeremy Stretch
f94792fad8 Merge pull request #10230 from netbox-community/10195-filterdevice
10195 Filter device components by Rack
2022-08-31 16:41:31 -04:00
jeremystretch
6df2ff7ebf #10195: Add filterset tests 2022-08-31 16:22:37 -04:00
Jeremy Stretch
dc4ddedca3 Merge pull request #10232 from netbox-community/9477-reset-column-ordering
Closes #9477: Add a button to clear applied table column ordering
2022-08-31 15:46:00 -04:00
jeremystretch
73b85f9b29 Minor tweaks to git cheat sheet 2022-08-31 15:44:06 -04:00
jeremystretch
ce054dd37d Closes #9477: Add a button to clear applied table column ordering 2022-08-31 15:28:59 -04:00
Arthur
3a0e91a688 #10195 add rack to component filter forms 2022-08-31 11:40:03 -07:00
Arthur
60ca4f29d7 #10195 add rack to component filter forms 2022-08-31 08:41:47 -07:00
jeremystretch
301ebe0da3 Add content to the git cheat sheet 2022-08-31 10:52:58 -04:00
jeremystretch
ada745324f Changelog for #10176, #10217 2022-08-31 10:36:01 -04:00
jeremystretch
a69eec5fb9 Further tweaks to the PR template 2022-08-31 10:36:01 -04:00
Jeremy Stretch
8c35ebbb7e Merge pull request #10227 from netbox-community/10217-cable-trace-split
Fixes #10217: Handle exception when trace splits to multiple rear ports
2022-08-31 10:25:41 -04:00
jeremystretch
5ef2d1d7ad Fixes #10217: Handle exception when trace splits to multiple rear ports 2022-08-31 10:07:10 -04:00
Jeremy Stretch
815b2d8a2b Merge pull request #10225 from netbox-community/10176
#10176 fix space utilization for rack
2022-08-31 08:55:39 -04:00
Arthur
0cfe2d882d #10176 fix space utilization for rack 2022-08-30 16:16:15 -07:00
Jeremy Stretch
6019b738a4 Merge pull request #10218 from netbox-community/10043-available-vlans-limit
Closes #10043: Add support for 'limit' query parameter to available VLANs API endpoint
2022-08-30 15:31:09 -04:00
jeremystretch
de17a651e6 Closes #10043: Add support for 'limit' query parameter to available VLANs API endpoint 2022-08-30 15:16:34 -04:00
jeremystretch
91dc9f0c9d Tweak PR template 2022-08-30 14:27:27 -04:00
jeremystretch
0770aa237a Changelog for #9895, #10060 2022-08-30 14:23:43 -04:00
Jeremy Stretch
d3f2d77961 Merge pull request #10187 from amhn/9895_Remove_JSONFieldInspector
9895 remove json field inspector
2022-08-30 14:21:02 -04:00
Jeremy Stretch
da03b22fe4 Merge pull request #10215 from netbox-community/10060-searchjournal
#10060 add journal entry to global search
2022-08-30 13:57:32 -04:00
Arthur
456647838c #10060 add journal entry to global search 2022-08-30 08:39:51 -07:00
Jeremy Stretch
e192ac34d0 Merge pull request #10213 from kkthxbye-code/10212-add-sidebar-padding
Fixes #10212 - Add padding to the bottom of the sidebar
2022-08-30 10:30:30 -04:00
kkthxbye
6a3ccda12e Add padding to the bottom of the sidebar 2022-08-30 15:57:04 +02:00
jeremystretch
14f79ef85a Closes #10202: Clean up TemplateColumn template code & accessors 2022-08-30 09:55:12 -04:00
jeremystretch
924471ee76 Clean up cable terminations display & edit form 2022-08-30 09:16:17 -04:00
jeremystretch
cd5844b050 Fixes #10208: Fix permissions evaluation for interface actions dropdown menu 2022-08-30 08:59:50 -04:00
jeremystretch
0f6a12b595 Changelog for #9962, #9963, #10178 2022-08-30 08:56:11 -04:00
Jeremy Stretch
d19c6a6afc Merge pull request #10173 from netbox-community/9962-ssonextparam
#9962 add next param to sso url
2022-08-30 08:53:58 -04:00
Jeremy Stretch
3257ea00b5 Merge pull request #10171 from netbox-community/10170-changelog
#10170 optimize change log queries
2022-08-30 08:50:01 -04:00
Jeremy Stretch
f073087379 Merge pull request #10205 from netbox-community/9963-csrftoken
#9963 change csrf token access
2022-08-30 08:48:23 -04:00
Jeremy Stretch
1ba47ae67c Merge pull request #10206 from netbox-community/10178-devicemanufacturer
#10178 add manufacturer to device detail view devicetype
2022-08-30 08:39:11 -04:00
Arthur
8ca3dfd8c9 #10178 add manufacturer to device detail view devicetype 2022-08-29 15:26:38 -07:00
Arthur
cdea30253b #101700 change csrf token access 2022-08-29 14:57:56 -07:00
Arthur
c8ee01ba0f #10170 changes from codereview 2022-08-29 14:53:28 -07:00
Arthur
3ad08c75c0 Revert "#101700 change csrf token access"
This reverts commit c12e545ccd.
2022-08-29 14:48:20 -07:00
Arthur
c12e545ccd #101700 change csrf token access 2022-08-29 14:40:03 -07:00
Jeremy Stretch
6571faad6c Merge pull request #10204 from netbox-community/10164-git-cheat-sheet
Closes #10164: Add a git cheat sheet
2022-08-29 16:01:31 -04:00
jeremystretch
f98f1647da Add "fixing mistakes" section 2022-08-29 15:40:39 -04:00
Arthur
249b8b0363 #10170 changes from code review 2022-08-29 12:20:51 -07:00
jeremystretch
777af35030 Fixes #10177: Correct display of custom fields when editing VM interfaces 2022-08-29 15:10:14 -04:00
Arthur
23fafe1996 Merge branch 'develop' into 10170-changelog 2022-08-29 12:08:39 -07:00
Jeremy Stretch
e0741cc9af Merge pull request #10200 from netbox-community/10181-api-file-upload
Fixes #10181: Restore MultiPartParser (regression from #10031)
2022-08-29 11:40:55 -04:00
jeremystretch
0e4911a575 Fixes #10181: Restore MultiPartParser (regression from #10031) 2022-08-29 11:17:31 -04:00
Jeremy Stretch
28bc76695a Merge pull request #10180 from ibrahimtok/patch-1
Update filtersets.md
2022-08-29 09:35:08 -04:00
Andreas Nieß
4f7287fec5 Add _occupied as BooleanField for nested serializers 2022-08-29 08:03:28 +02:00
Andreas Nieß
0b5478ad2d Fixes #9895: Change DictField serializers to JSONField 2022-08-29 08:02:38 +02:00
Andreas Nieß
cd6911f83c Replace custom JSONField inspector with the one from drf_yasg 2022-08-29 07:52:29 +02:00
itok
173f27cb64 Update filtersets.md
corrected typos on the page,
an issue report has also been submitted at https://github.com/netbox-community/netbox/issues/10179
regards,
2022-08-28 13:53:53 +03:00
jeremystretch
152dcbe522 Initial draft of git cheat sheet 2022-08-26 16:52:00 -04:00
Arthur
2a7bad326d #9962 add next param to sso url 2022-08-26 12:55:35 -07:00
Arthur
cb6d8bf063 #10170 optimize change log queries 2022-08-26 10:46:47 -07:00
jeremystretch
83db8d2072 Clarify that issues must be accepted and assigned prior to PR submission 2022-08-26 13:33:55 -04:00
jeremystretch
3da8e4c1bb Update README & screenshots 2022-08-26 10:31:56 -04:00
jeremystretch
ee111a28d4 Changelog for #10161 2022-08-26 10:05:44 -04:00
Jeremy Stretch
d00d9cb00f Merge pull request #10162 from kkthxbye-code/10161-fix-cf-setnull
Fixes #10161 - Render "Set Null" button for custom fields
2022-08-26 09:44:21 -04:00
Jeremy Stretch
74ba5a61bf Merge pull request #10163 from netbox-community/10156-svg-link-target
Fixes #10156: Avoid forcing SVG image links to open in a new window
2022-08-26 09:41:37 -04:00
jeremystretch
fd81f57e61 Fixes #10156: Avoid forcing SVG image links to open in a new window 2022-08-26 08:45:48 -04:00
kkthxbye-code
e75e189933 Render "Set Null" button for custom fields 2022-08-26 14:32:17 +02:00
Daniel Sheppard
88d2fca2c6 Fixes #10155 - Fix rear port display when editing front port template for module type 2022-08-25 15:34:18 -05:00
jeremystretch
bd615ebf65 PRVB 2022-08-25 16:25:26 -04:00
125 changed files with 1947 additions and 1083 deletions

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
<!--
Thank you for your interest in contributing to NetBox! Please note
that our contribution policy requires that a feature request or bug
report be opened for approval prior to filing a pull request. This
helps avoid wasting time and effort on something that we might not
be able to accept.
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.
Please indicate the relevant feature request or bug report below.
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
TO YOU, IT WE BE CLOSED AUTOMATICALLY.
Specify your assigned issue number on the line below.
-->
### Fixes: <ISSUE NUMBER GOES HERE>
### Fixes: #1234
<!--
Please include a summary of the proposed changes below.
-->

View File

@@ -9,13 +9,9 @@ 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

@@ -102,23 +102,28 @@ appropriate labels will be applied for categorization.
[getting started](https://docs.netbox.dev/en/stable/development/getting-started/)
documentation for tips on setting up your development environment.
* Be sure to open an issue **before** starting work on a pull request, and
discuss your idea with the NetBox maintainers before beginning work. This will
help prevent wasting time on something that might we might not be able to
implement. When suggesting a new feature, also make sure it won't conflict with
any work that's already in progress.
* Be sure to open an issue and wait for it to be assigned to you **before**
starting work on a pull request, and discuss your idea with the NetBox
maintainers before beginning work. This will help prevent wasting time on
proposed changes that we might not be able to accept. When suggesting a new
feature, also make sure it won't conflict with any work that's already in
progress.
* Once you've opened or identified an issue you'd like to work on, ask that it
be assigned to you so that others are aware it's being worked on. A maintainer
will then mark the issue as "accepted."
be assigned to you so that others are aware it's being worked on. If it meets
the acceptance criteria, a maintainer will then mark the issue as "accepted"
and assign it to you. (Note that GitHub requires that a user first comment on
an issue before it can be assigned to that user.)
* Any pull request which does _not_ relate to an **accepted** issue will be closed.
* Any pull request which does not relate to an **assigned** issue will be
closed.
* All new functionality must include relevant tests where applicable.
* When submitting a pull request, please be sure to work off of the `develop`
branch, rather than `master`. The `develop` branch is used for ongoing
development, while `master` is used for tagging stable releases.
development, while `master` is used for tagging stable releases. (If you're
developing for the next minor release, use `feature` instead.)
* In most cases, it is not necessary to add a changelog entry: A maintainer will
take care of this when the PR is merged. (This helps avoid merge conflicts
@@ -136,8 +141,10 @@ these checks):
Only comment on an issue if you are sharing a relevant idea or constructive
feedback. **Do not** comment on an issue just to show your support (give the
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
reduce noise in the discussion.
top post a :+1: instead) or to ask for an update. Doing so generates
unnecessary noise in the discussion, and is especially annoying for people who
have subscribed to updates for the issue. Any comments without substance
relevant to the discussion will be deleted.
## Issue Lifecycle

View File

@@ -2,14 +2,24 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div>
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,
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)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower
network automation, used by thousands of organizations around the world.
Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
[![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:
@@ -21,6 +31,7 @@ Myriad infrastructure components can be modeled in NetBox, including:
* Virtual machines and clusters
* IP prefixes, ranges, and addresses
* VRFs and route targets
* L2VPN and overlays
* FHRP groups (VRRP, HSRP, etc.)
* AS numbers
* VLANs and scoped VLAN groups
@@ -45,14 +56,16 @@ customized and extended through the use of:
NetBox also features a complete REST API as well as a GraphQL API for easily
integrating with other tools and systems.
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/).
A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev).
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). A public demo instance is available at https://demo.netbox.dev.
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;
@@ -90,8 +103,6 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
### Screenshots
![Screenshot of main page (light mode)](docs/media/screenshots/home-light.png "Main page (light mode)")
![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
![Screenshot of rack elevation](docs/media/screenshots/rack.png "Rack elevation")

View File

@@ -0,0 +1,388 @@
# git Cheat Sheet
This cheat sheet serves as a convenient reference for NetBox contributors who already somewhat familiar with using git. For a general introduction to the tooling and workflows involved, please see GitHub's guide [Getting started with git](https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git).
## Common Operations
### Clone a Repo
This copies a remote git repository (e.g. from GitHub) to your local workstation. It will create a new directory bearing the repo's name in the current path.
``` title="Command"
git clone https://github.com/$org-name/$repo-name
```
``` title="Example"
$ git clone https://github.com/netbox-community/netbox
Cloning into 'netbox'...
remote: Enumerating objects: 95112, done.
remote: Counting objects: 100% (682/682), done.
remote: Compressing objects: 100% (246/246), done.
remote: Total 95112 (delta 448), reused 637 (delta 436), pack-reused 94430
Receiving objects: 100% (95112/95112), 60.40 MiB | 45.82 MiB/s, done.
Resolving deltas: 100% (74979/74979), done.
```
### Pull New Commits
To update your local branch with any recent upstream commits, run `git pull`.
``` title="Command"
git pull
```
``` title="Example"
$ git pull
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (1/1), done.
From https://github.com/netbox-community/netbox
28bc76695..e0741cc9a develop -> origin/develop
Updating 28bc76695..e0741cc9a
Fast-forward
docs/release-notes/version-3.3.md | 1 +
netbox/netbox/settings.py | 1 +
2 files changed, 2 insertions(+)
```
### List Branches
`git branch` lists all local branches. Appending `-a` to this command will list both local (green) and remote (red) branches.
``` title="Command"
git branch -a
```
``` title="Example"
$ git branch -a
* develop
remotes/origin/10170-changelog
remotes/origin/HEAD -> origin/develop
remotes/origin/develop
remotes/origin/feature
remotes/origin/master
```
### Switch Branches
To switch to a different branch, use the `checkout` command.
``` title="Command"
git checkout $branchname
```
``` title="Example"
$ git checkout feature
Branch 'feature' set up to track remote branch 'feature' from 'origin'.
Switched to a new branch 'feature'
```
### Create a New Branch
Use the `-b` argument with `checkout` to create a new _local_ branch from the current branch.
``` title="Command"
git checkout -b $newbranch
```
``` title="Example"
$ git checkout -b 123-fix-foo
Switched to a new branch '123-fix-foo'
```
### Rename a Branch
To rename the current branch, use the `git branch` command with the `-m` argument (for "modify").
``` title="Command"
git branch -m $newname
```
``` title="Example"
$ git branch -m jstretch-testing
$ git branch
develop
feature
* jstretch-testing
```
### Merge a Branch
To merge one branch into another, use the `git merge` command. Start by checking out the _destination_ branch, and merge the _source_ branch into it.
``` title="Command"
git merge $sourcebranch
```
``` title="Example"
$ git checkout testing
Switched to branch 'testing'
Your branch is up to date with 'origin/testing'.
$ git merge branch2
Updating 9a12b5b5f..8ee42390b
Fast-forward
newfile.py | 0
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 newfile.py
```
!!! warning "Avoid Merging Remote Branches"
You generally want to avoid merging branches that exist on the remote (upstream) repository, such as `develop` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally.
### Show Pending Changes
After making changes to files in the repo, `git status` will display a summary of created, modified, and deleted files.
``` title="Command"
git status
```
``` title="Example"
$ git status
On branch 123-fix-foo
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: README.md
Untracked files:
(use "git add <file>..." to include in what will be committed)
foo.py
no changes added to commit (use "git add" and/or "git commit -a")
```
### Stage Changed Files
Before creating a new commit, modified files must be staged. This is typically done with the `git add` command. You can specify a particular path, or just append `-A` to automatically staged _all_ changed files within the current directory. Run `git status` again to verify what files have been staged.
``` title="Command"
git add -A
```
``` title="Example"
$ git add -A
$ git status
On branch 123-fix-foo
Changes to be committed:
(use "git reset HEAD <file>..." to unstage)
modified: README.md
new file: foo.py
```
### Review Staged Files
It's a good idea to thoroughly review all staged changes immediately prior to creating a new commit. This can be done using the `git diff` command. Appending the `--staged` argument will show staged changes; omitting it will show changes that have not yet been staged.
``` title="Command"
git diff --staged
```
``` title="Example"
$ git diff --staged
diff --git a/README.md b/README.md
index 93e125079..4344fb514 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,8 @@
+
+Added some lines here
+and here
+and here too
+
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div>
diff --git a/foo.py b/foo.py
new file mode 100644
index 000000000..e69de29bb
```
### Create a New Commit
The `git commit` command records your changes to the current branch. Specify a commit message with the `-m` argument. (If omitted, a file editor will be opened to provide a message.
``` title="Command"
git commit -m "Fixes #123: Fixed the thing that was broken"
```
``` title="Example"
$ git commit -m "Fixes #123: Fixed the thing that was broken"
[123-fix-foo 9a12b5b5f] Fixes #123: Fixed the thing that was broken
2 files changed, 5 insertions(+)
create mode 100644 foo.py
```
!!! tip "Automatically Closing Issues"
GitHub will [automatically close](https://github.blog/2013-01-22-closing-issues-via-commit-messages/) any issues referenced in a commit message by `Fixes:` or `Closes:` when the commit is merged into the repository's default branch. Contributors are strongly encouraged to follow this convention when forming commit messages. (Use "Closes" for feature requests and "Fixes" for bugs.)
### Push a Commit Upstream
Once you've made a commit locally, it needs to be pushed upstream to the _remote_ repository (typically called "origin"). This is done with the `git push` command. If this is a new branch that doesn't yet exist on the remote repository, you'll need to set the upstream for it when pushing.
``` title="Command"
git push -u origin $branchname
```
``` title="Example"
$ git push -u origin testing
Counting objects: 3, done.
Delta compression using up to 16 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 377 bytes | 377.00 KiB/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Resolving deltas: 100% (2/2), completed with 2 local objects.
remote:
remote: Create a pull request for 'testing' on GitHub by visiting:
remote: https://github.com/netbox-community/netbox/pull/new/testing
remote:
To https://github.com/netbox-community/netbox
* [new branch] testing -> testing
Branch 'testing' set up to track remote branch 'testing' from 'origin'.
```
!!! tip
You can apply the following git configuration to automatically set the upstream for all new branches. This obviates the need to specify `-u origin`.
```
git config --global push.default current
```
## The GitHub CLI Client
GitHub provides a [free CLI client](https://cli.github.com/) to simplify many aspects of interacting with GitHub repositories. Note that this utility is separate from `git`, and must be [installed separately](https://github.com/cli/cli#installation).
This guide provides some examples of common operations, but be sure to check out the [GitHub CLI manual](https://cli.github.com/manual/) for a complete accounting of available commands.
### List Open Pull Requests
``` title="Command"
gh pr list
```
``` title="Example"
$ gh pr list
Showing 3 of 3 open pull requests in netbox-community/netbox
#10223 #7503 API Bulk-Create of Devices does not check Rack-Space 7503-bulkdevice about 17 hours ago
#9716 Closes #9599: Add cursor pagination mode lyuyangh:cursor-pagination about 1 month ago
#9498 Adds replication and adoption for module import sleepinggenius2:issue_9361 about 2 months ago
```
### Check Out a PR
This command will automatically check out the remote branch associated with an open pull request.
``` title="Command"
gh pr checkout $number
```
``` title="Example"
$ gh pr checkout 10223
Branch '7503-bulkdevice' set up to track remote branch '7503-bulkdevice' from 'origin'.
Switched to a new branch '7503-bulkdevice'
```
## Fixing Mistakes
### Modify the Previous Commit
Sometimes you'll find that you've overlooked a necessary change and need to commit again. If you haven't pushed your most recent commit and just need to make a small tweak or two, you can _amend_ your most recent commit instead of creating a new one.
First, stage the desired files with `git add` and verify the changes, the issue the `git commit` command with the `--amend` argument. You can also append the `--no-edit` argument if you would like to keep the previous commit message.
``` title="Command"
git commit --amend --no-edit
```
``` title="Example"
$ git add -A
$ git diff --staged
$ git commit --amend --no-edit
[testing 239b16921] Added a new file
Date: Fri Aug 26 16:30:05 2022 -0400
2 files changed, 1 insertion(+)
create mode 100644 newfile.py
```
!!! danger "Don't Amend After Pushing"
Never amend a commit you've already pushed upstream unless you're **certain** no one else is working on the same branch. Force-pushing will overwrite the change history, which will break any commits from other contributors. When in doubt, create a new commit instead.
### Undo the Last Commit
The `git reset` command can be used to undo the most recent commit. (`HEAD~` is equivalent to `HEAD~1` and references the commit prior to the current HEAD.) After making and staging your changes, commit using `-c ORIG_HEAD` to replace the erroneous commit.
``` title="Command"
git reset HEAD~
```
``` title="Example"
$ git add -A
$ git commit -m "Erroneous commit"
[testing 09ce06736] Erroneous commit
Date: Mon Aug 29 15:20:04 2022 -0400
1 file changed, 1 insertion(+)
create mode 100644 BADCHANGE
$ git reset HEAD~
$ rm BADFILE
$ git add -A
$ git commit -m "Fixed commit"
[testing c585709f3] Fixed commit
Date: Mon Aug 29 15:22:38 2022 -0400
1 file changed, 65 insertions(+), 20 deletions(-)
```
!!! danger "Don't Reset After Pushing"
Resetting only works until you've pushed your local changes upstream. If you've already pushed upstream, use `git revert` instead. This will create a _new_ commit that reverts the erroneous one, but ensures that the git history remains intact.
### Rebase from Upstream
If a change has been pushed to the upstream branch since you most recently pulled it, attempting to push a new local commit will fail:
```
$ git push
To https://github.com/netbox-community/netbox.git
! [rejected] develop -> develop (fetch first)
error: failed to push some refs to 'https://github.com/netbox-community/netbox.git'
hint: Updates were rejected because the remote contains work that you do
hint: not have locally. This is usually caused by another repository pushing
hint: to the same ref. You may want to first integrate the remote changes
hint: (e.g., 'git pull ...') before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
```
To resolve this, first fetch the upstream branch to update your local copy, and then [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) your local branch to include the new changes. Once the rebase has completed, you can push your local commits upstream.
``` title="Commands"
git fetch
git rebase origin/$branchname
```
``` title="Example"
$ git fetch
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
Unpacking objects: 100% (1/1), done.
From https://github.com/netbox-community/netbox
815b2d8a2..8c35ebbb7 develop -> origin/develop
$ git rebase origin/develop
First, rewinding head to replay your work on top of it...
Applying: Further tweaks to the PR template
Applying: Changelog for #10176, #10217
$ git push
Counting objects: 9, done.
Delta compression using up to 16 threads.
Compressing objects: 100% (9/9), done.
Writing objects: 100% (9/9), 1.02 KiB | 1.02 MiB/s, done.
Total 9 (delta 6), reused 0 (delta 0)
remote: Resolving deltas: 100% (6/6), completed with 5 local objects.
To https://github.com/netbox-community/netbox.git
8c35ebbb7..ada745324 develop -> develop
```

View File

@@ -4,6 +4,8 @@
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, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure.
[![NetBox UI](./media/screenshots/netbox-ui.png)](./media/screenshots/netbox-ui.png)
## :material-server-network: Built for Networks
Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more:

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"

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -34,12 +34,12 @@ To utilize a filter set in a subclass of one of NetBox's generic views (such as
```python
# views.py
from netbox.views.generic import ObjectListView
from .filtersets import MyModelFitlerSet
from .filtersets import MyModelFilterSet
from .models import MyModel
class MyModelListView(ObjectListView):
queryset = MyModel.objects.all()
filterset = MyModelFitlerSet
filterset = MyModelFilterSet
```
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:

View File

@@ -1,5 +1,77 @@
# NetBox v3.3
## 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
* [#9477](https://github.com/netbox-community/netbox/issues/9477) - Enable clearing applied table column ordering
* [#10034](https://github.com/netbox-community/netbox/issues/10034) - Add L2VPN column to interface and VLAN tables
* [#10043](https://github.com/netbox-community/netbox/issues/10043) - Add support for `limit` query parameter to available VLANs API endpoint
* [#10060](https://github.com/netbox-community/netbox/issues/10060) - Add journal entries to global search
* [#10195](https://github.com/netbox-community/netbox/issues/10195) - Enable filtering of device components by rack
* [#10233](https://github.com/netbox-community/netbox/issues/10233) - Enable sorting rack elevations by facility ID
### Bug Fixes
* [#9328](https://github.com/netbox-community/netbox/issues/9328) - Hide available IPs when non-default ordering is applied
* [#9481](https://github.com/netbox-community/netbox/issues/9481) - Update child device location when parent location changes
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Improve error message when validating rack reservation units
* [#9895](https://github.com/netbox-community/netbox/issues/9895) - Various corrections to OpenAPI spec
* [#9962](https://github.com/netbox-community/netbox/issues/9962) - SSO login should respect `next` URL query parameter
* [#9963](https://github.com/netbox-community/netbox/issues/9963) - Fix support for custom `CSRF_COOKIE_NAME` value
* [#10155](https://github.com/netbox-community/netbox/issues/10155) - Fix rear port display when editing front port template for module type
* [#10156](https://github.com/netbox-community/netbox/issues/10156) - Avoid forcing SVG image links to open in a new window
* [#10161](https://github.com/netbox-community/netbox/issues/10161) - Restore "set null" option for custom fields during bulk edit
* [#10176](https://github.com/netbox-community/netbox/issues/10176) - Correct utilization display for empty racks
* [#10177](https://github.com/netbox-community/netbox/issues/10177) - Correct display of custom fields when editing VM interfaces
* [#10178](https://github.com/netbox-community/netbox/issues/10178) - Display manufacturer name alongside device type under device view
* [#10181](https://github.com/netbox-community/netbox/issues/10181) - Restore MultiPartParser (regression from #10031)
* [#10184](https://github.com/netbox-community/netbox/issues/10184) - Fix vertical alignment when displaying object attributes with buttons
* [#10208](https://github.com/netbox-community/netbox/issues/10208) - Fix permissions evaluation for interface actions dropdown menu
* [#10217](https://github.com/netbox-community/netbox/issues/10217) - Handle exception when trace splits to multiple rear ports
* [#10220](https://github.com/netbox-community/netbox/issues/10220) - Validate IP version when assigning primary IPs to a virtual machine
* [#10231](https://github.com/netbox-community/netbox/issues/10231) - Correct API schema definition for several serializer fields
---
## v3.3.1 (2022-08-25)
### Enhancements

View File

@@ -249,6 +249,7 @@ nav:
- User Preferences: 'development/user-preferences.md'
- Web UI: 'development/web-ui.md'
- Release Checklist: 'development/release-checklist.md'
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 3.3: 'release-notes/version-3.3.md'

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

@@ -316,6 +316,7 @@ class NestedModuleSerializer(WritableNestedSerializer):
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsoleServerPort
@@ -325,6 +326,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
class NestedConsolePortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.ConsolePort
@@ -334,6 +336,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerOutlet
@@ -343,6 +346,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
class NestedPowerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerPort
@@ -352,6 +356,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
class NestedInterfaceSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.Interface
@@ -361,6 +366,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
class NestedRearPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.RearPort
@@ -370,6 +376,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
class NestedFrontPortSerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.FrontPort
@@ -454,6 +461,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
_occupied = serializers.BooleanField(required=False, read_only=True)
class Meta:
model = models.PowerFeed

View File

@@ -579,7 +579,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_component(self, obj):
if obj.component is None:
return None
@@ -693,13 +693,13 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_config_context(self, obj):
return obj.get_config_context()
class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
method = serializers.JSONField()
#
@@ -975,7 +975,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
'custom_fields', 'created', 'last_updated', '_depth',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_component(self, obj):
if obj.component is None:
return None
@@ -1046,7 +1046,7 @@ class CableTerminationSerializer(NetBoxModelSerializer):
'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination'
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_termination(self, obj):
serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
@@ -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

@@ -1096,7 +1096,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)
@@ -1084,6 +1104,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='slug',
label='Location (slug)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
rack = django_filters.ModelMultipleChoiceFilter(
field_name='device__rack__name',
queryset=Rack.objects.all(),
to_field_name='name',
label='Rack (name)',
)
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
@@ -1133,6 +1164,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):

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

@@ -87,6 +87,15 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
},
label=_('Location')
)
rack_id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
},
label=_('Rack')
)
virtual_chassis_id = DynamicModelMultipleChoiceField(
queryset=VirtualChassis.objects.all(),
required=False,
@@ -356,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',
@@ -377,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',
@@ -927,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', 'virtual_chassis_id', 'device_id')),
('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,
@@ -945,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', 'virtual_chassis_id', 'device_id')),
('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,
@@ -963,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', 'virtual_chassis_id', 'device_id')),
('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,
@@ -977,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', 'virtual_chassis_id', 'device_id')),
('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,
@@ -991,7 +1043,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
tag = TagFilterField(model)
class InterfaceFilterForm(DeviceComponentFilterForm):
class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
(None, ('q', 'tag')),
@@ -999,7 +1051,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
('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,
@@ -1080,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', 'virtual_chassis_id', 'device_id')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
)
model = FrontPort
type = MultipleChoiceField(
@@ -1097,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', 'virtual_chassis_id', 'device_id')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
)
type = MultipleChoiceField(
choices=PortTypeChoices,
@@ -1119,7 +1174,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
@@ -1132,7 +1187,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
tag = TagFilterField(model)
@@ -1142,7 +1197,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),

View File

@@ -986,47 +986,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,43 +1073,56 @@ 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,
query_params={
'devicetype_id': '$device_type',
'moduletype_id': '$module_type',
}
)
fieldsets = (
(None, (
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
'description',
)),
)
class Meta:
model = FrontPortTemplate
fields = [
@@ -1079,48 +1130,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,
@@ -1147,22 +1200,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,
@@ -1171,25 +1241,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:
@@ -1198,42 +1274,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,
@@ -1242,6 +1308,13 @@ class PowerOutletForm(NetBoxModelForm):
}
)
fieldsets = (
(None, (
'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
'tags',
)),
)
class Meta:
model = PowerOutlet
fields = [
@@ -1249,20 +1322,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,
@@ -1330,8 +1395,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')),
@@ -1351,7 +1422,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm):
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': SelectSpeedWidget(),
'poe_mode': StaticSelect(),
@@ -1370,25 +1440,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={
@@ -1396,6 +1449,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 = [
@@ -1403,18 +1463,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:
@@ -1423,33 +1480,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):
@@ -1472,10 +1528,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,

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

@@ -281,15 +281,11 @@ class CableTermination(models.Model):
# 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):
@@ -677,6 +673,12 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
rearports = self.path_objects[-1]
nodes = self.path_objects[-1]
return FrontPort.objects.filter(rear_port__in=rearports)
# RearPort splitting to multiple FrontPorts with no stack position
if type(nodes[0]) is RearPort:
return FrontPort.objects.filter(rear_port__in=nodes)
# Cable terminating to multiple FrontPorts mapped to different
# RearPorts connected to different cables
elif type(nodes[0]) is FrontPort:
return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes])

View File

@@ -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

@@ -168,6 +168,10 @@ class DeviceType(NetBoxModel):
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@property
def get_full_name(self):
return f"{ self.manufacturer } { self.model }"
def to_yaml(self):
data = {
'manufacturer': self.manufacturer.name,
@@ -864,6 +868,7 @@ class Device(NetBoxModel, ConfigContextModel):
for device in devices:
device.site = self.site
device.rack = self.rack
device.location = self.location
device.save()
@property

View File

@@ -350,7 +350,7 @@ class Rack(NetBoxModel):
# Remove units without enough space above them to accommodate a device of the specified height
available_units = []
for u in units:
if set(drange(u, u + u_height, 0.5)).issubset(units):
if set(drange(u, u + decimal.Decimal(u_height), 0.5)).issubset(units):
available_units.append(u)
return list(reversed(available_units))
@@ -415,12 +415,13 @@ class Rack(NetBoxModel):
"""
# Determine unoccupied units
total_units = len(list(self.units))
available_units = self.get_available_units()
available_units = self.get_available_units(u_height=0.5)
# Remove reserved units
for u in self.get_reserved_units():
if u in available_units:
available_units.remove(u)
for ru in self.get_reserved_units():
for u in drange(ru, ru + 1, 0.5):
if u in available_units:
available_units.remove(u)
occupied_unit_count = total_units - len(available_units)
percentage = float(occupied_unit_count) / total_units * 100

View File

@@ -94,7 +94,7 @@ class Connector(Group):
self.add(cable)
# Add link
link = Hyperlink(href=url, target='_blank')
link = Hyperlink(href=url, target='_parent')
# Add text label(s)
cursor = start[1]
@@ -281,7 +281,7 @@ class CableTraceSVG:
self.cursor += PADDING * 2
# Add link
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank')
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
# Add text label(s)
for i, label in enumerate(labels):

View File

@@ -151,7 +151,7 @@ class RackElevationSVG:
css_extra = ' shaded' if is_shaded else ''
# Create hyperlink element
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank')
link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent")
link.set_desc(description)
# Add rect element to hyperlink
@@ -235,10 +235,7 @@ class RackElevationSVG:
self.margin_width,
u_height * self.unit_height
)
link = Hyperlink(
href='{}{}'.format(self.base_url, reservation.get_absolute_url()),
target='_blank'
)
link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent')
link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}')
link.add(
Rect(coords, size, class_='reservation')
@@ -268,7 +265,7 @@ class RackElevationSVG:
y_offset + self.unit_height / 2
)
link = Hyperlink(href=url_string.format(unit), target='_blank')
link = Hyperlink(href=url_string.format(unit), target='_parent')
link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot'))
link.add(Text('add device', insert=text_coords, class_='add-device'))

View File

@@ -143,6 +143,15 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
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 +161,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable):
rack = tables.Column(
linkify=True
)
position = columns.TemplateColumn(
template_code='{{ value|floatformat }}'
)
device_role = columns.ColoredLabelColumn(
verbose_name='Role'
)
@@ -199,10 +211,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',
@@ -483,6 +495,12 @@ class BaseInterfaceTable(NetBoxTable):
orderable=False,
verbose_name='FHRP Groups'
)
l2vpn = tables.Column(
accessor=tables.A('l2vpn_termination__l2vpn'),
linkify=True,
orderable=False,
verbose_name='L2VPN'
)
untagged_vlan = tables.Column(linkify=True)
tagged_vlans = columns.TemplateColumn(
template_code=INTERFACE_TAGGED_VLANS,
@@ -520,8 +538,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses',
'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -554,8 +572,8 @@ class DeviceInterfaceTable(InterfaceTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag',
'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan',
'tagged_vlans', 'actions',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions',
)
order_by = ('name',)
default_columns = (

View File

@@ -51,7 +51,7 @@ class RackTable(TenancyColumnsMixin, NetBoxTable):
status = columns.ChoiceFieldColumn()
role = columns.ColoredLabelColumn()
u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U",
template_code="{{ value }}U",
verbose_name='Height'
)
comments = columns.MarkdownColumn()

View File

@@ -4,7 +4,7 @@ LINKTERMINATION = """
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>{% if not forloop.last %},{% endif %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>{% if not forloop.last %}<br />{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
@@ -33,7 +33,7 @@ DEVICEBAY_STATUS = """
INTERFACE_IPADDRESSES = """
<div class="table-badge-group">
{% for ip in record.ip_addresses.all %}
{% for ip in value.all %}
{% if ip.status != 'active' %}
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_color }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
{% else %}
@@ -53,7 +53,7 @@ INTERFACE_FHRPGROUPS = """
INTERFACE_TAGGED_VLANS = """
{% if record.mode == 'tagged' %}
{% for vlan in record.tagged_vlans.all %}
{% for vlan in value.all %}
<a href="{{ vlan.get_absolute_url }}">{{ vlan }}</a><br />
{% endfor %}
{% elif record.mode == 'tagged-all' %}
@@ -62,7 +62,7 @@ INTERFACE_TAGGED_VLANS = """
"""
INTERFACE_WIRELESS_LANS = """
{% for wlan in record.wireless_lans.all %}
{% for wlan in value.all %}
<a href="{{ wlan.get_absolute_url }}">{{ wlan }}</a><br />
{% endfor %}
"""
@@ -226,7 +226,7 @@ POWEROUTLET_BUTTONS = """
"""
INTERFACE_BUTTONS = """
{% if perms.dcim.edit_interface %}
{% if perms.dcim.change_interface %}
<span class="dropdown">
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Add">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
@@ -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

@@ -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)
@@ -1924,10 +1936,17 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -1976,12 +1995,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]}
@@ -2003,6 +2016,20 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2015,17 +2042,22 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'module_id': [modules[0].pk, modules[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_location(self):
locations = Location.objects.all()[:2]
params = {'location_id': [locations[0].pk, locations[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'location': [locations[0].slug, locations[1].slug]}
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)
@@ -2071,10 +2103,17 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2123,12 +2162,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]}
@@ -2157,6 +2190,13 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2170,9 +2210,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)
@@ -2218,10 +2270,17 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2278,12 +2337,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]}
@@ -2312,6 +2365,13 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2325,9 +2385,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)
@@ -2373,10 +2445,17 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2429,12 +2508,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]}
@@ -2463,6 +2536,13 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -2476,9 +2556,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)
@@ -2524,10 +2616,17 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2678,12 +2777,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)
@@ -2793,6 +2886,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2810,9 +2910,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):
@@ -2899,10 +3011,17 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -2994,6 +3113,13 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -3007,9 +3133,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)
@@ -3055,10 +3187,17 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections
)
Device.objects.bulk_create(devices)
@@ -3144,6 +3283,13 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -3157,9 +3303,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)
@@ -3204,10 +3356,17 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3258,6 +3417,13 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -3307,10 +3473,17 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3361,6 +3534,13 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -3416,10 +3596,17 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations:
location.save()
racks = (
Rack(name='Rack 1', site=sites[0]),
Rack(name='Rack 2', site=sites[1]),
Rack(name='Rack 3', site=sites[2]),
)
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]),
)
Device.objects.bulk_create(devices)
@@ -3503,6 +3690,13 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'location': [locations[0].slug, locations[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_rack(self):
racks = Rack.objects.all()[:2]
params = {'rack_id': [racks[0].pk, racks[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'rack': [racks[0].name, racks[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -4019,9 +4213,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,
}
@@ -1912,6 +1887,7 @@ class ModuleTestCase(
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1935,9 +1911,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 +1946,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsoleServerPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -1993,7 +1970,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 +2003,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2051,7 +2029,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 +2066,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = PowerOutlet
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2119,7 +2098,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 +2132,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2217,7 +2197,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 +2257,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = FrontPort
validation_excluded_fields = ('name', 'label', 'rear_port')
@classmethod
def setUpTestData(cls):
@@ -2312,11 +2293,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 +2327,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = RearPort
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2372,7 +2352,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 +2386,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ModuleBay
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2428,7 +2409,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 +2428,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = DeviceBay
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2472,7 +2454,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 +2473,7 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = InventoryItem
validation_excluded_fields = ('name', 'label')
@classmethod
def setUpTestData(cls):
@@ -2525,7 +2508,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

@@ -589,10 +589,17 @@ class RackElevationListView(generic.ObjectListView):
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Determine ordering
reverse = bool(request.GET.get('reverse', False))
if reverse:
racks = racks.reverse()
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")
if sort not in ORDERING_CHOICES:
sort = 'name'
racks = racks.order_by(sort)
# Pagination
per_page = get_paginate_count(request)
@@ -614,7 +621,9 @@ class RackElevationListView(generic.ObjectListView):
'paginator': paginator,
'page': page,
'total_count': total_count,
'reverse': reverse,
'sort': sort,
'sort_display_name': ORDERING_CHOICES[sort],
'sort_choices': ORDERING_CHOICES,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
})
@@ -1111,9 +1120,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):
@@ -1146,9 +1154,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):
@@ -1181,9 +1188,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):
@@ -1216,9 +1222,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):
@@ -1251,9 +1256,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):
@@ -1288,15 +1292,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):
@@ -1329,9 +1324,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):
@@ -1366,8 +1360,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):
@@ -1400,9 +1392,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):
@@ -1435,9 +1426,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)
@@ -1865,14 +1855,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):
@@ -1924,14 +1913,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):
@@ -1983,14 +1971,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):
@@ -2042,14 +2029,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):
@@ -2145,42 +2131,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):
@@ -2235,19 +2192,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):
@@ -2299,14 +2247,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):
@@ -2360,13 +2307,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):
@@ -2414,14 +2359,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):
@@ -2543,7 +2487,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)
@@ -2727,7 +2670,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

@@ -192,7 +192,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_parent(self, obj):
serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX)
return serializer(obj.parent, context={'request': self.context['request']}).data
@@ -242,7 +242,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}
@@ -403,6 +403,7 @@ class ScriptSerializer(serializers.Serializer):
vars = serializers.SerializerMethodField(read_only=True)
result = NestedJobResultSerializer()
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_vars(self, instance):
return {
k: v.__class__.__name__ for k, v in instance._get_vars().items()
@@ -461,7 +462,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_changed_object(self, obj):
"""
Serialize a nested representation of the changed object.

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

@@ -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

@@ -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']

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']

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

@@ -183,6 +183,7 @@ class ObjectChangeTable(NetBoxTable):
verbose_name='Username'
)
full_name = tables.TemplateColumn(
accessor=tables.A('user'),
template_code=OBJECTCHANGE_FULL_NAME,
verbose_name='Full Name',
orderable=False
@@ -192,6 +193,7 @@ class ObjectChangeTable(NetBoxTable):
verbose_name='Type'
)
object_repr = tables.TemplateColumn(
accessor=tables.A('changed_object'),
template_code=OBJECTCHANGE_OBJECT,
verbose_name='Object'
)

View File

@@ -9,12 +9,12 @@ CONFIGCONTEXT_ACTIONS = """
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ record.user.get_full_name|placeholder }}
{{ value.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """
{% if record.changed_object and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
{% if value and value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ record.object_repr }}</a>
{% else %}
{{ record.object_repr }}
{% endif %}

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

@@ -143,7 +143,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_interface(self, obj):
if obj.interface is None:
return None
@@ -190,6 +190,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
]
validators = []
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_scope(self, obj):
if obj.scope_id is None:
return None
@@ -373,7 +374,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
'custom_fields', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
@@ -482,7 +483,7 @@ class L2VPNTerminationSerializer(NetBoxModelSerializer):
'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated'
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_assigned_object(self, instance):
serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
context = {'request': self.context['request']}

View File

@@ -174,6 +174,21 @@ class L2VPNTerminationViewSet(NetBoxModelViewSet):
# Views
#
def get_results_limit(request):
"""
Return the lesser of the specified limit (if any) and the configured MAX_PAGE_SIZE.
"""
config = get_config()
try:
limit = int(request.query_params.get('limit', config.PAGINATE_COUNT)) or config.MAX_PAGE_SIZE
except ValueError:
limit = config.PAGINATE_COUNT
if config.MAX_PAGE_SIZE:
limit = min(limit, config.MAX_PAGE_SIZE)
return limit
class AvailablePrefixesView(ObjectValidationMixin, APIView):
queryset = Prefix.objects.all()
@@ -265,16 +280,7 @@ class AvailableIPAddressesView(ObjectValidationMixin, APIView):
@swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
def get(self, request, pk):
parent = self.get_parent(request, pk)
config = get_config()
PAGINATE_COUNT = config.PAGINATE_COUNT
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
try:
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
except ValueError:
limit = PAGINATE_COUNT
if MAX_PAGE_SIZE:
limit = min(limit, MAX_PAGE_SIZE)
limit = get_results_limit(request)
# Calculate available IPs within the parent
ip_list = []
@@ -357,8 +363,9 @@ class AvailableVLANsView(ObjectValidationMixin, APIView):
@swagger_auto_schema(responses={200: serializers.AvailableVLANSerializer(many=True)})
def get(self, request, pk):
vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk)
available_vlans = vlangroup.get_available_vids()
limit = get_results_limit(request)
available_vlans = vlangroup.get_available_vids()[:limit]
serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={
'request': request,
'group': vlangroup,

View File

@@ -854,6 +854,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

@@ -10,7 +10,7 @@ __all__ = (
IPADDRESSES = """
{% for ip in record.ip_addresses.all %}
{% for ip in value.all %}
<a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
{% endfor %}
"""

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 %}
@@ -47,7 +54,7 @@ IPADDRESS_ASSIGN_LINK = """
"""
VRF_LINK = """
{% if record.vrf %}
{% if value %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
{% elif object.vrf %}
<a href="{{ object.vrf.get_absolute_url }}">{{ object.vrf }}</a>
@@ -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'),

View File

@@ -30,7 +30,7 @@ VLAN_LINK = """
"""
VLAN_PREFIXES = """
{% for prefix in record.prefixes.all %}
{% for prefix in value.all %}
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
{% endfor %}
"""
@@ -110,6 +110,12 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
role = tables.Column(
linkify=True
)
l2vpn = tables.Column(
accessor=tables.A('l2vpn_termination__l2vpn'),
linkify=True,
orderable=False,
verbose_name='L2VPN'
)
prefixes = columns.TemplateColumn(
template_code=VLAN_PREFIXES,
orderable=False,
@@ -122,8 +128,8 @@ class VLANTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLAN
fields = (
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', 'description', 'tags',
'created', 'last_updated',
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role',
'description', 'tags', 'l2vpn', 'created', 'last_updated',
)
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {

View File

@@ -699,9 +699,18 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
"""
Test retrieval of all available VLANs within a group.
"""
self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
vlangroup = VLANGroup.objects.first()
MIN_VID = 100
MAX_VID = 199
self.add_permissions('ipam.view_vlangroup', 'ipam.view_vlan')
vlangroup = VLANGroup.objects.create(
name='VLAN Group X',
slug='vlan-group-x',
min_vid=MIN_VID,
max_vid=MAX_VID
)
# Create a set of VLANs within the group
vlans = (
VLAN(vid=10, name='VLAN 10', group=vlangroup),
VLAN(vid=20, name='VLAN 20', group=vlangroup),
@@ -711,13 +720,17 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
# Retrieve all available VLANs
url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 4094 - len(vlans))
response = self.client.get(f'{url}?limit=0', **self.header)
self.assertEqual(len(response.data), MAX_VID - MIN_VID + 1)
available_vlans = {vlan['vid'] for vlan in response.data}
for vlan in vlans:
self.assertNotIn(vlan.vid, available_vlans)
# Retrieve a maximum number of available VLANs
url = reverse('ipam-api:vlangroup-available-vlans', kwargs={'pk': vlangroup.pk})
response = self.client.get(f'{url}?limit=10', **self.header)
self.assertEqual(len(response.data), 10)
def test_create_single_available_vlan(self):
"""
Test the creation of a single available VLAN.

View File

@@ -526,9 +526,8 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group')
def prep_table_data(self, request, queryset, parent):
if not request.GET.get('q'):
if not request.GET.get('q') and not request.GET.get('sort'):
return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
return queryset
def get_extra_context(self, request, instance):

View File

@@ -38,7 +38,7 @@ class GenericObjectSerializer(serializers.Serializer):
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_object(self, obj):
serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX)
# context = {'request': self.context['request']}

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,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 *
@@ -32,7 +31,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 +51,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
abstract = True
class NetBoxModel(NetBoxFeatureSet, models.Model):
class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
"""
Primary models represent real objects within the infrastructure being modeled.
"""
@@ -61,25 +60,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

@@ -2,6 +2,8 @@ import circuits.filtersets
import circuits.tables
import dcim.filtersets
import dcim.tables
import extras.filtersets
import extras.tables
import ipam.filtersets
import ipam.tables
import tenancy.filtersets
@@ -15,6 +17,7 @@ from dcim.models import (
Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
VirtualChassis,
)
from extras.models import JournalEntry
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
from tenancy.models import Contact, Tenant, ContactAssignment
from utilities.utils import count_related
@@ -238,6 +241,15 @@ WIRELESS_TYPES = {
},
}
JOURNAL_TYPES = {
'journalentry': {
'queryset': JournalEntry.objects.prefetch_related('assigned_object', 'created_by'),
'filterset': extras.filtersets.JournalEntryFilterSet,
'table': extras.tables.JournalEntryTable,
'url': 'extras:journalentry_list',
},
}
SEARCH_TYPE_HIERARCHY = {
'Circuits': CIRCUIT_TYPES,
'DCIM': DCIM_TYPES,
@@ -245,6 +257,7 @@ SEARCH_TYPE_HIERARCHY = {
'Tenancy': TENANCY_TYPES,
'Virtualization': VIRTUALIZATION_TYPES,
'Wireless': WIRELESS_TYPES,
'Journal': JOURNAL_TYPES,
}

View File

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
# Environment setup
#
VERSION = '3.3.1'
VERSION = '3.3.4'
# Hostname
HOSTNAME = platform.node()
@@ -535,6 +535,7 @@ REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination',
'DEFAULT_PARSER_CLASSES': (
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.MultiPartParser',
),
'DEFAULT_PERMISSION_CLASSES': (
'netbox.api.authentication.TokenPermissions',
@@ -575,7 +576,6 @@ SWAGGER_SETTINGS = {
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
'DEFAULT_FIELD_INSPECTORS': [
'utilities.custom_inspectors.CustomFieldsDataFieldInspector',
'utilities.custom_inspectors.JSONFieldInspector',
'utilities.custom_inspectors.NullableBooleanFieldInspector',
'utilities.custom_inspectors.ChoiceFieldInspector',
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
@@ -585,6 +585,7 @@ SWAGGER_SETTINGS = {
'drf_yasg.inspectors.ChoiceFieldInspector',
'drf_yasg.inspectors.FileFieldInspector',
'drf_yasg.inspectors.DictFieldInspector',
'drf_yasg.inspectors.JSONFieldInspector',
'drf_yasg.inspectors.SerializerMethodFieldInspector',
'drf_yasg.inspectors.SimpleFieldInspector',
'drf_yasg.inspectors.StringDefaultFieldInspector',

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

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

@@ -538,10 +538,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 +548,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 +619,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),
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -27,7 +27,6 @@
"bootstrap": "~5.0.2",
"clipboard": "^2.0.8",
"color2k": "^1.2.4",
"cookie": "^0.4.1",
"dayjs": "^1.10.4",
"flatpickr": "4.6.3",
"htmx.org": "^1.6.1",

View File

@@ -1,9 +1,11 @@
import Cookie from 'cookie';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
type SelectedOption = { name: string; options: string[] };
declare global {
interface Window { CSRF_TOKEN: any; }
}
/**
* Infer valid HTMLElement props based on element name.
*/
@@ -93,23 +95,12 @@ export function isElement(obj: Element | null | undefined): obj is Element {
return typeof obj !== null && typeof obj !== 'undefined';
}
/**
* Retrieve the CSRF token from cookie storage.
*/
function getCsrfToken(): string {
const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
if (typeof csrfToken === 'undefined') {
throw new Error('Invalid or missing CSRF token');
}
return csrfToken;
}
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
url: string,
method: Method,
data?: D,
): Promise<APIResponse<R>> {
const token = getCsrfToken();
const token = window.CSRF_TOKEN;
const headers = new Headers({ 'X-CSRFToken': token });
let body;

View File

@@ -235,12 +235,12 @@ table {
}
}
th.asc a::after {
th.asc > a::after {
content: "\f0140";
font-family: 'Material Design Icons';
}
th.desc a::after {
th.desc > a::after {
content: "\f0143";
font-family: 'Material Design Icons';
}

View File

@@ -737,11 +737,6 @@ configstore@^3.0.0:
write-file-atomic "^2.0.0"
xdg-basedir "^3.0.0"
cookie@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
copy-to-clipboard@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz#115aa1a9998ffab6196f93076ad6da3b913662ae"

View File

@@ -99,6 +99,7 @@
}
return setMode("light", true);
})();
window.CSRF_TOKEN = "{{ csrf_token }}";
</script>
{# Static resources #}

View File

@@ -28,7 +28,7 @@
<div class="sidenav-inner h-100 mb-auto">
{# Collapse #}
<div class="collapse sidenav-collapse">
<div class="collapse sidenav-collapse pb-4">
{# Nav Items #}
{% nav %}

View File

@@ -1,18 +1,8 @@
{% extends 'base/layout.html' %}
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% block title %}Connect Cable{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a href="#" role="tab" data-bs-toggle="tab" class="nav-link active">Connect Cable</a>
</li>
</ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% render_errors form %}
@@ -116,8 +106,12 @@
</div>
<div class="row my-3">
<div class="col col-md-12 text-center">
{% if object.pk %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
{% else %}
<button type="submit" name="_create" class="btn btn-primary">Create</button>
{% endif %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Connect</button>
</div>
</div>
</form>

View File

@@ -22,16 +22,18 @@
<h3 class="text-danger">Path split!</h3>
<p>Select a node below to continue:</p>
<ul class="text-start">
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
<li>
<a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
(Cable {{ next_node.cable|linkify }})
</li>
{% else %}
<li class="text-muted">{{ next_node }}</li>
{% endif %}
{% endfor %}
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
{% with viewname=next_node|viewname:"trace" %}
<li>
<a href="{% url viewname pk=next_node.pk %}">{{ next_node|meta:"verbose_name"|bettertitle }} {{ next_node }}</a>
(Cable {{ next_node.cable|linkify }})
</li>
{% endwith %}
{% else %}
<li class="text-muted">{{ next_node }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<h3 class="text-center text-success">Trace Completed</h3>

View File

@@ -1,38 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
{% if form.module_type %}
<div class="row mb-2">
<div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="devicetype_tab" data-bs-toggle="tab" aria-controls="devicetype" data-bs-target="#devicetype" class="nav-link {% if not form.initial.module_type %}active{% endif %}">
Device Type
</button>
</li>
<li role="presentation" class="nav-item">
<button role="tab" type="button" id="moduletype_tab" data-bs-toggle="tab" aria-controls="moduletype" data-bs-target="#moduletype" class="nav-link {% if form.initial.module_type %}active{% endif %}">
Module Type
</button>
</li>
</ul>
</div>
</div>
<div class="tab-content p-0 border-0">
<div class="tab-pane {% if not form.initial.module_type %}active{% endif %}" id="devicetype" role="tabpanel">
{% render_field replication_form.device_type %}
</div>
<div class="tab-pane {% if form.initial.module_type %}active{% endif %}" id="moduletype" role="tabpanel">
{% render_field replication_form.module_type %}
</div>
</div>
{% else %}
{% render_field replication_form.device_type %}
{% endif %}
{% block replication_fields %}
{% render_field replication_form.name_pattern %}
{% render_field replication_form.label_pattern %}
{% endblock replication_fields %}
{{ block.super }}
{% endblock form %}

View File

@@ -46,10 +46,10 @@
</tr>
<tr>
<th scope="row">Rack</th>
<td>
<td class="position-relative">
{% if object.rack %}
{{ object.rack|linkify }}
<div class="float-end noprint">
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm" title="Highlight device">
<i class="mdi mdi-view-day-outline"></i>
</a>
@@ -70,7 +70,7 @@
{% endif %}
{% endwith %}
{% elif object.rack and object.position %}
<span>U{{ object.position }} / {{ object.get_face_display }}</span>
<span>U{{ object.position|floatformat }} / {{ object.get_face_display }}</span>
{% elif object.rack and object.device_type.u_height %}
<span class="badge bg-warning">Not racked</span>
{% else %}
@@ -90,7 +90,7 @@
<tr>
<th scope="row">Device Type</th>
<td>
{{ object.device_type|linkify }} ({{ object.device_type.u_height }}U)
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U)
</td>
</tr>
<tr>

View File

@@ -1,16 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="field-group mb-5">
{% if form.instance.device %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">Device</label>
<div class="col">
<input class="form-control" value="{{ form.instance.device }}" disabled />
</div>
</div>
{% endif %}
{% render_form form %}
</div>
{% endblock form %}

View File

@@ -1,7 +0,0 @@
{% extends 'dcim/component_template_create.html' %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% render_field replication_form.rear_port_set %}
{% endblock replication_fields %}

View File

@@ -1,17 +0,0 @@
{% extends 'dcim/component_create.html' %}
{% load helpers %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% if object.component %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">
{{ object.component|meta:"verbose_name"|bettertitle }}
</label>
<div class="col">
<input class="form-control" value="{{ object.component }}" disabled />
</div>
</div>
{% endif %}
{% endblock replication_fields %}

View File

@@ -1,17 +0,0 @@
{% extends 'dcim/component_template_create.html' %}
{% load helpers %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% if object.component %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end">
{{ object.component|meta:"verbose_name"|bettertitle }}
</label>
<div class="col">
<input class="form-control" value="{{ object.component }}" disabled />
</div>
</div>
{% endif %}
{% endblock replication_fields %}

View File

@@ -58,9 +58,9 @@
</tr>
<tr>
<th scope="row">Racks</th>
<td>
<td class="position-relative">
{% if rack_count %}
<div class="float-end noprint">
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?location_id={{ object.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i>
</a>

View File

@@ -1,7 +0,0 @@
{% extends 'dcim/component_template_create.html' %}
{% load form_helpers %}
{% block replication_fields %}
{{ block.super }}
{% render_field replication_form.position_pattern %}
{% endblock replication_fields %}

View File

@@ -18,9 +18,15 @@
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-outline-secondary{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-outline-secondary{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div>
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse=None %}" class="btn btn-outline-secondary{% if not reverse %} active{% endif %}">Normal</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-outline-secondary{% if reverse %} active{% endif %}">Reversed</a>
<div class="dropdown">
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-sort"></i>&nbsp;Sort By {{ sort_display_name }}
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for sort_key, sort_display_name in sort_choices.items %}
<li><a class="dropdown-item{% if sort == sort_key %} active{% endif %}" href="{% url 'dcim:rack_elevation_list' %}{% querystring request sort=sort_key %}">{{ sort_display_name }}</a></li>
{% endfor %}
</ul>
</div>
</div>
</div>

View File

@@ -85,11 +85,11 @@
</tr>
<tr>
<th scope="row">Physical Address</th>
<td>
<td class="position-relative">
{% if object.physical_address %}
<div class="float-end noprint">
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
<i class="mdi mdi-map-marker"></i> Map
</a>
</div>
<span>{{ object.physical_address|linebreaksbr }}</span>
@@ -104,9 +104,9 @@
</tr>
<tr>
<th scope="row">GPS Coordinates</th>
<td>
<td class="position-relative">
{% if object.latitude and object.longitude %}
<div class="float-end noprint">
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
</a>

View File

@@ -55,7 +55,7 @@
<td>{{ device.pk }}</td>
<td>
{% if device.rack %}
{{ device.rack }} / {{ device.position }}
{{ device.rack }} / {{ device.position|floatformat }}
{% else %}
{{ ''|placeholder }}
{% endif %}

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