Compare commits

...

253 Commits

Author SHA1 Message Date
Jeremy Stretch
6638fd88b4 Merge pull request #11793 from netbox-community/develop
Release v3.4.5
2023-02-21 09:01:01 -05:00
jeremystretch
c280ca35d6 Release v3.4.5 2023-02-21 08:45:52 -05:00
jeremystretch
3586cf79d4 Arrange parameters alphabetically 2023-02-21 08:42:39 -05:00
jeremystretch
972ba7bfdc #11685: Fix migration 2023-02-20 10:27:30 -05:00
jeremystretch
5a4d8a7107 Closes #11787: Rebuild any missing search cache entires after upgrade 2023-02-20 09:49:13 -05:00
jeremystretch
3e946c78d0 #11685: Clear cached search records for relevant IPAM objects 2023-02-20 09:02:58 -05:00
jeremystretch
0855ff8b42 Skip clearing cache when handling new objects 2023-02-20 08:17:39 -05:00
jeremystretch
cd09501d4d #11685: Omit no-op migration 2023-02-19 20:08:57 -05:00
jeremystretch
e635e3e959 Fixes #11658: Remove reindex command call from search migration 2023-02-19 18:57:27 -05:00
jeremystretch
9efc4689cc Changelog for #11685 2023-02-19 18:57:27 -05:00
kkthxbye-code
25278becef Change Prefix and Aggregate search index weights to better order search results. 2023-02-19 18:50:24 -05:00
kkthxbye-code
fc7cb106c1 Address feedback 2023-02-19 18:50:24 -05:00
kkthxbye-code
18ea7d1e13 pep8 fixes 2023-02-19 18:50:24 -05:00
kkthxbye-code
eed1b8f412 Create CachedValueField to contain search specific lookups 2023-02-19 18:50:24 -05:00
kkthxbye-code
a61e7e7c04 Fix typo in search query 2023-02-19 18:50:24 -05:00
kkthxbye-code
ce166b12ce Proof of concept for showing containing prefixes when searching for ip-addresses. 2023-02-19 18:50:24 -05:00
jeremystretch
315371bf7c Fixes #11786: List only applicable object types in form widget when filtering custom fields 2023-02-19 16:17:57 -05:00
jeremystretch
afc752b4ce Fixes #11723: Circuit terminations should link to their associated circuits (rather than site or provider network) 2023-02-17 21:31:19 -05:00
jeremystretch
126f9ba05f Raise stale timers from 60/30 to 90/30 2023-02-17 16:57:52 -05:00
jeremystretch
c031951f4b Closes #11110: Add start_address and end_address filters for IP ranges 2023-02-17 16:50:10 -05:00
jeremystretch
c36e7a1d0b Update introduction doc 2023-02-17 10:11:39 -05:00
jeremystretch
3a4fee4e6e Changelog for #11226, #11335, #11473, #11592 2023-02-16 20:15:48 -05:00
Aron Bergur Jóhannsson
2db181ea49 Closes #11592: Expose FILE_UPLOAD_MAX_MEMORY_SIZE as a setting (#11742)
* Closes #11592: Expose FILE_UPLOAD_MAX_MEMOMORY_SIZE as a setting

* change configuration settings to alphabetic order

* Small example and documentation

---------

Co-authored-by: aron bergur jóhannsson <aronnemi@gmail.com>
2023-02-16 11:26:22 -05:00
aron bergur jóhannsson
eee1a0e10a change empty list to qs.none() 2023-02-16 11:06:57 -05:00
aron bergur jóhannsson
9594049804 Fixes #11473 graphql invalid tag filter returns all devices/interfaces 2023-02-16 11:06:57 -05:00
kkthxbye-code
c78022a74c Change the way we invalidate the module cache to support reloading code from subpackages 2023-02-16 10:50:38 -05:00
Jeremy Stretch
3150c1f8b3 Changelog for #11459, #11711 2023-02-13 17:58:41 -05:00
Jeremy Stretch
9f91b89467 #11711: Use CSVModelChoiceField for custom object fields during CSV import 2023-02-13 17:53:01 -05:00
kkthxbye
d748851027 Fixes #11711 - Use CSVModelMultipleChoiceField when importing custom multiple object fields (#11712)
* Fixes #11711 - Use CSVModelMultipleChoiceField when importing custom multiple object fields

* Fix pep8

---------

Co-authored-by: kkthxbye-code <>
2023-02-13 17:49:08 -05:00
kkthxbye
df499ea8ac Fixes #11459 - Allow using null in conditions (#11722)
* Fixes #11459 - Allow using null in conditions
- Update docs to reflect this
- Change docs example from primary_ip to primary_ip4 as computed properties are not serialized when queuing webhooks

* Update netbox/extras/conditions.py

---------

Co-authored-by: Simon Toft <SITO@telenor.dk>
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-02-13 17:44:35 -05:00
jeremystretch
b5da383a17 Changelog for #11032, #11582, #11601 2023-02-08 14:56:14 -05:00
kkthxbye-code
f9237285fd Fixes #11601 - Add partial lookup to IPRangeFilterSet 2023-02-08 14:50:22 -05:00
kkthxbye-code
3c970c331c Fixes #11582: Fix missing VC form errors
### Fixes: #11582

Not sure if this is the correct fix or not. The reason that the custom field errors were not shown is that messages.html only shows non_field_errors if the form passed to the context is named form. This is probably an issue in more places, but not sure how to make it generic. A change to messages.html would also need to support formsets.

Any input appreciated @jeremystretch or @arthanson
2023-02-08 14:40:46 -05:00
kkthxbye
91705aa9fd Fixes #11032 - Replication fields broken in custom validation (#11698)
* Fixes #11032 - Replication fields broken in custom validation

* Use getattr instead of hasattr to make sure custom validation is triggered as normal

---------

Co-authored-by: kkthxbye-code <>
2023-02-08 14:36:20 -05:00
jeremystretch
56c7a238a4 Fixes #11683: Fix CSV header attribute detection when auto-detecting import format 2023-02-07 17:24:26 -05:00
jeremystretch
3f28d6aef3 Add step for creating search index 2023-02-07 16:55:50 -05:00
jeremystretch
edbd597bf2 Update housekeeping command docs 2023-02-07 16:52:54 -05:00
jeremystretch
5e1bb20f32 Display login message as success 2023-02-07 16:49:07 -05:00
jeremystretch
7ebfa4c1d1 PRVB 2023-02-02 15:41:24 -05:00
Jeremy Stretch
65417dbf9e Merge pull request #11655 from netbox-community/develop
Release v3.4.4
2023-02-02 15:39:38 -05:00
jeremystretch
37d0135cab Release v3.4.4 2023-02-02 15:24:54 -05:00
Maximilian Wilhelm
699edd049c Closes #11152: Add support to abort custom script gracefully (#11621)
Signed-off-by: Maximilian Wilhelm <max@sdn.clinic>
2023-02-02 15:22:55 -05:00
jeremystretch
95b2acb603 Fixes #11650: Display error message when attempting to create device component with duplicate name 2023-02-02 14:59:16 -05:00
jeremystretch
98a2f3e497 Refresh the README 2023-02-02 14:18:32 -05:00
Abhimanyu Saharan
fb2771370c handled scripts error when only interval is used 2023-02-02 10:25:19 -05:00
jeremystretch
a137cd6cbe Fixes #11635: Pre-populate assigned VRF when following "first available IP" link from prefix view 2023-02-01 12:28:54 -05:00
Arthur
10e27cfa00 11620 fix interface poe type filter 2023-02-01 10:24:20 -05:00
jeremystretch
46ede62f3f Fix rendering of example code 2023-01-30 10:25:20 -05:00
jeremystretch
e7ad6eeb74 Fixes #11613: Correct plugin import logic fix from #11267 2023-01-27 19:56:12 -05:00
jeremystretch
892fd95b5f Update NetBox Cloud link 2023-01-27 16:46:49 -05:00
jeremystretch
0da518e83d Changelog for #11267 2023-01-27 16:45:20 -05:00
Jeremy Stretch
fbc9fea0a5 Fixes #11267: Avoid catching ImportError exceptions when loading plugins (#11566)
* Avoid catching ImportErrors when loading plugin URLs

* Avoid catching ImportErrors when loading plugin resources
2023-01-27 16:44:10 -05:00
jeremystretch
ccc108a217 Closes #11598: Add buttons to easily switch between rack list and elevations views 2023-01-26 10:53:59 -05:00
jeremystretch
22a9df82e6 Closes #11554: Add module types count to manufacturers list 2023-01-26 08:46:25 -05:00
jeremystretch
9cb75e9834 Closes #11585: Add IP address filters for services 2023-01-25 21:25:25 -05:00
jeremystretch
55b1549895 Closes #10762: Permit selection custom fields to have only one choice 2023-01-25 10:27:05 -05:00
jeremystretch
6f74c5ec03 Fixes #11528: Show edit/delete buttons in user tokens table 2023-01-25 10:09:37 -05:00
jeremystretch
b8de9c0875 Fixes #11528: Permit import of devices using uploaded file 2023-01-25 09:55:45 -05:00
jeremystretch
d5ccda355f Fixes #11562: Correct ordering of virtual chassis interfaces with duplicate names 2023-01-24 15:44:02 -05:00
jeremystretch
b79a2976f7 Closes #10888, #10889: Add supplementary notes to installation docs 2023-01-24 14:40:09 -05:00
jeremystretch
39087d10eb Add NetBox Labs as a sponsor 2023-01-23 10:44:42 -05:00
jeremystretch
6a793087b4 Reference GitHub advisory reporting 2023-01-23 10:23:49 -05:00
jeremystretch
0f9a303963 Changelog for #11487 2023-01-23 10:21:11 -05:00
Arthur Hanson
eca624b13d 11487 remove set null from read-only custom fields bulk edit (#11552)
* 11487 remove set null from read-only custom fields bulk edit

* 11487 removes unreleased sentry-sdk
2023-01-23 08:48:14 -05:00
jeremystretch
a4d8169df8 Changelog for #11537 2023-01-20 16:48:22 -05:00
jeremystretch
5f7e310305 Fixes #11555: Avoid inadvertent interpretation of search query as regular expression under global search 2023-01-20 16:47:19 -05:00
jeremystretch
d5e6829eff PRVB 2023-01-20 14:21:03 -05:00
Jeremy Stretch
504800a7db Merge pull request #11551 from netbox-community/develop
Release v3.4.3
2023-01-20 14:19:15 -05:00
jeremystretch
97723b1f96 Correct pinned sentry-sdk version 2023-01-20 13:53:28 -05:00
jeremystretch
5911041777 #11516: Tweak fix to ensure proper highlighting 2023-01-20 13:43:47 -05:00
jeremystretch
fcd0481b09 Release v3.4.3 2023-01-20 13:10:21 -05:00
jeremystretch
cc350165dd Fixes #11544: Catch ValidationError exception when filtering by invalid MAC address 2023-01-20 12:06:34 -05:00
Arthur
db7e1b8a97 11537 remove connection from power feed table 2023-01-20 11:52:56 -05:00
jeremystretch
188f773081 Changelog for #11118, #11227, #11228 2023-01-20 10:24:57 -05:00
reishoku
6271f81cff Add 800GbE interface support: QSFP-DD OSFP (#11429)
Signed-off-by: KOSHIKAWA Kenichi <reishoku.misc@pm.me>

Signed-off-by: KOSHIKAWA Kenichi <reishoku.misc@pm.me>
2023-01-20 10:09:53 -05:00
jeremystretch
4bfc3bf412 #11118: Extend L2VPN filters to device & VM interfaces 2023-01-20 09:58:58 -05:00
Abhimanyu Saharan
d5a92104d1 added l2vpn_termination on vlan filterset (#11501)
* added l2vpn_termination on vlan filterset

* added l2vpn to vlan filterset
2023-01-20 09:34:41 -05:00
Abhimanyu Saharan
ddd4f805a5 added device and vm tab on device role (#11500)
* added vm tab on device role

* added blank lines

* updated templates

* fixed lint issues
2023-01-20 09:30:18 -05:00
Jeremy Stretch
a1c1b19482 Changelog for #11433, #11516 2023-01-17 21:22:02 -05:00
Abhimanyu Saharan
426bc15065 fixed AttributeError: object of class Schema has no attribute fields 2023-01-17 21:12:06 -05:00
kkthxbye-code
df5febf6e7 Add re.escape to highlight_string 2023-01-17 20:42:17 -05:00
jeremystretch
9e09e46700 Fixes #11522: Correct tag links under contact & tenant list views 2023-01-17 10:32:22 -05:00
jeremystretch
ba0e9bb1d2 Changelog for #11488, #11497 2023-01-17 10:27:53 -05:00
jeremystretch
19da92b510 #11488: Additional cleanup 2023-01-17 10:26:34 -05:00
Abhimanyu Saharan
beb1f4e172 added missing description field on the api serializers 2023-01-17 10:20:34 -05:00
kkthxbye-code
fb3d1ef399 Check for the extras.run_script permission when running scripts via. the API 2023-01-17 10:13:18 -05:00
jeremystretch
d7c37d9dd6 Fixes #11483: Apply configured formatting to custom date fields 2023-01-13 08:23:57 -05:00
jeremystretch
24de404fbc Changelog for #11416 2023-01-12 09:37:52 -05:00
jeremystretch
8565d175f9 Fixes #11467: Correct count on interfaces tab when viewing a VC master device 2023-01-12 09:05:55 -05:00
Arthur
8d9e151030 11416 fix circuit termination deletion 2023-01-11 16:09:39 -05:00
jeremystretch
758c5347fb Fixes #10201: Fix AssertionError exception when removing some terminations from an existing cable 2023-01-11 14:42:25 -05:00
Jonathan Senecal
1e54eee631 Update ipaddress.md
Missing `ipam` before `IPAddress.status`
2023-01-11 09:45:28 -05:00
jeremystretch
448760a2fe Add items to contributing guide 2023-01-10 15:47:33 -05:00
jeremystretch
e44b22f7d1 Refresh contributing guide 2023-01-10 08:41:06 -05:00
jeremystretch
30379c3f52 Changelog for #11438, #11444 2023-01-09 10:58:23 -05:00
jeremystretch
8729d60c1c Fixes #11402: Avoid LookupError exception when running scripts with commit disabled 2023-01-09 10:57:13 -05:00
kkthxbye
effcdb8723 Snapshot DeviceBay before populating/depopulating. 2023-01-09 08:39:54 -05:00
kkthxbye
1354947434 Get the queue from QUEUE_MAPPINGS when deleting JobResults 2023-01-09 08:22:40 -05:00
jeremystretch
864ce0088e Changelog for #10486, #11210, #11340, #11379 2023-01-06 16:25:41 -05:00
jeremystretch
93ac0b77c9 Fixes #11379: Fix TypeError exception when bulk editing custom date fields 2023-01-06 16:23:38 -05:00
Mario
ea327e6b37 Closes #10486: Add buttons to edit cables (#11414)
* Added buttons to edit cables

* Revert change that did not address this branch

* Consolidated buttons

* moved back trace button / added permission checks

* reverted disabled trace button
2023-01-06 15:11:29 -05:00
kkthxbye-code
f7b85ab941 Return no terminations if the cable is unsaved 2023-01-06 14:57:07 -05:00
Arthur Hanson
ce9933da85 11340 cable termination setter (#11341)
* 11340 update _terminations_modified only if modified

* 11340 update _terminations_modified only if modified
2023-01-06 10:15:43 -05:00
Robin Schneider
0662f0dab4 Add summary release notes for v3.4 2023-01-06 10:07:21 -05:00
jeremystretch
0669fda1fd Fixes #11384: Correct current time display on script & report forms 2023-01-06 09:42:13 -05:00
jeremystretch
b88fcb6625 Closes #11406: Correct example JSON 2023-01-05 16:38:29 -05:00
jeremystretch
69be24cd5f Changelog for #9996, #11150, #11245, #11371, #11403 2023-01-05 16:29:17 -05:00
Renato Almeida de Oliveira
35273cc87f Add ExportTemplatesMixin to JournalEntry model (#11251)
* Add ExportTemplatesMixin to JournalEntry model

* Move mixin ahead of base class

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2023-01-05 16:26:48 -05:00
Daniel W. Anner
5af73e9bf7 #11371 - Add various 100Mb Interface Types (#11377)
* Added 100base-fx (aka fast ethernet over fiber optic)

* Added 100BASE-T1 (single pair fast ethernet) as well as 100BASE‑LFX (fast ethernet over fiber, non standard)

* Update choices.py

Updated the placing of the 100base-fx and lfx choices

* Update netbox/dcim/choices.py

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2023-01-05 16:26:26 -05:00
Patrick Kerwood
128ccb4330 feat: added setting redis certificate authority path 2023-01-05 16:15:26 -05:00
Robin Schneider
07df622b59 NetBox should always be referred to as NetBox [DATALAD RUNCMD]
=== Do not change lines below ===
{
 "chain": [],
 "cmd": "git ls-files -z . | xargs --null -I '()' find './()' -type f -not -name 'style-guide.md' -print0 | xargs --null sed --in-place --regexp-extended 's/\\bNetbox\\b/NetBox/g;'",
 "exit": 0,
 "extra_inputs": [],
 "inputs": [],
 "outputs": [],
 "pwd": "."
}
^^^ Do not change lines above ^^^
2023-01-05 16:06:00 -05:00
Michaël Arnauts
5d22260589 #11150: Add a filter for device.primary_ip4 and primary_ip6 (#11382)
* Closes #11150: Add a filter for device.primary_ip4 and primary_ip6

* Tweaked tests to query for multiple IDs

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-05 14:25:15 -05:00
kkthxbye-code
39985ebdd1 Fix exception when scheduling a job in the past 2023-01-05 13:52:07 -05:00
jeremystretch
92ec06c694 PRVB 2023-01-03 16:30:17 -05:00
Jeremy Stretch
04137e887e Merge pull request #11376 from netbox-community/develop
Release v3.4.2
2023-01-03 16:28:05 -05:00
jeremystretch
e940f00c01 Release v3.4.2 2023-01-03 16:13:11 -05:00
jeremystretch
1c72a80d9a Changelog for #11156, #11259, #11342, #11345 2023-01-03 10:21:19 -05:00
kkthxbye
b9f8370097 Fixes #11156 - Allow InventoryItem component reassignment (#11256)
* Allow re-assigning InventoryItem components

* Refactor logic for finding initial component assignment on InventoryItems

* PEP8 fix

* Fix wrong HTML causing tab list to extend past the end of the parent row

* Tweak form field labels

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 10:13:34 -05:00
Christian Harendt
1c636ea127 add username for redis authentication 2023-01-03 09:42:18 -05:00
kkthxbye
e1169e7ea6 Fixes #11345 - Fix module validation (#11346)
* Make sure we bail out if field validation failed when importing modules

* Tweak form validation logic

Co-authored-by: jeremystretch <jstretch@ns1.com>
2023-01-03 09:14:25 -05:00
kkthxbye-code
5975dbcb07 Fix component traces all pointing to the interface trace URL 2023-01-03 08:25:28 -05:00
Arthur Hanson
08a419ec7a 11271 flag to disable localization (#11323)
* 11271 flag to disable localization

* 11271 change to remove middleware

* 11271 update docs for new var

* Update docs/configuration/system.md

Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
Co-authored-by: kkthxbye <400797+kkthxbye-code@users.noreply.github.com>
2022-12-29 09:04:35 -05:00
jeremystretch
d417168805 Changelog for #11223, #11244, #11248 2022-12-28 16:58:04 -05:00
Mario
ccb2966c4c Fixes #11244: Elevations: Filter badge missing (#11321)
* Added filter badge in rack elevation

* Tweak template context

Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
2022-12-28 16:54:33 -05:00
Alef Burzmali
b7cdbd3d41 Fixes #11248 - Reindex only NetBox apps 2022-12-28 16:35:05 -05:00
Alef Burzmali
ae440c9edf Fixes #11223 - Accept app_label for reindex 2022-12-28 16:35:05 -05:00
jeremystretch
b6cd099117 Changelog for #11121, #11280 2022-12-27 16:07:02 -05:00
Arthur
98f57f2dba 11297 have custom field form display content-type instead of model 2022-12-27 15:50:08 -05:00
kkthxbye-code
735fa4aa31 Add summed resource card to cluster view 2022-12-27 10:24:46 -05:00
kkthxbye
c7108bb3f7 Fixes #11280 - Fix exporting interfaces and FHRP group rows with multiple IP's assigned (#11285)
undefined
2022-12-27 10:15:28 -05:00
jeremystretch
98b3fc03b8 Changelog for #9285, #10700, #11290 2022-12-22 10:14:38 -05:00
kkthxbye-code
92da2fe082 Add device name as part of module search for the q filter 2022-12-22 10:09:53 -05:00
kkthxbye-code
bfab3a26bc Add component import to InventoryItem bulk import 2022-12-22 09:59:50 -05:00
kkthxbye-code
b35b33e798 Use the start time to calculate duration of jobs instead of created time 2022-12-22 09:52:05 -05:00
jeremystretch
db5c2a379e Fixes #11232: Enable partial & regex matching for non-string types in global search 2022-12-22 09:14:57 -05:00
jeremystretch
3675ad2539 PRVB 2022-12-16 17:18:06 -05:00
Jeremy Stretch
27c71b8ec0 Merge pull request #11219 from netbox-community/develop
Release v3.4.1
2022-12-16 17:16:07 -05:00
jeremystretch
0058c7749c Release v3.4.1 2022-12-16 17:03:40 -05:00
jeremystretch
f882dcabf7 Fixes #11184: Correct visualization of cable path which splits across multiple circuit terminations 2022-12-16 16:45:51 -05:00
Arthur Hanson
c8f4a7c742 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS (#11207)
* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS

* 11206 dont remove user groups if no valid REMOTE_AUTH_DEFAULT_GROUPS
2022-12-16 08:59:24 -05:00
jeremystretch
ed366c5ab2 Closes #11214: Introduce the DEFAULT_LANGUAGE configuration parameter 2022-12-16 08:56:14 -05:00
jeremystretch
2738da2d39 Fixes #11189: Fix localization of dates & numbers 2022-12-16 08:43:05 -05:00
jeremystretch
9f15ca2d90 Closes #9971: Enable ordering of nested group models by name 2022-12-15 16:21:30 -05:00
jeremystretch
e4f5407c70 Changelog for #11175, #11178 2022-12-15 16:05:43 -05:00
jeremystretch
951f82b428 Fixes #11205: Correct cloning behavior for recursively-nested models 2022-12-15 16:04:29 -05:00
Arthur Hanson
f8685ad7aa 11175 fix cloning special chars in fields (#11181)
* 11175 fix cloning special chars in fields

* 11175 fix cloning special chars in fields
2022-12-15 13:07:55 -05:00
Arthur
c59d527664 11178 fix quick search press enter button 2022-12-15 13:01:21 -05:00
jeremystretch
77423e7bb1 Fixes #11185: Fix TemplateSyntaxError when viewing custom script results 2022-12-15 12:55:09 -05:00
jeremystretch
ba12675267 PRVB 2022-12-14 14:24:46 -05:00
Jeremy Stretch
def3ccfaee Merge pull request #11180 from netbox-community/develop
Release v3.4.0
2022-12-14 14:18:49 -05:00
jeremystretch
bbc68f9484 Release v3.4.0 2022-12-14 13:10:22 -05:00
Jeremy Stretch
93685d92a4 Merge pull request #11179 from netbox-community/feature
Prepare for v3.4.0 release
2022-12-14 11:56:29 -05:00
Arthur Hanson
b2bf613895 11171 fix graphql related models query (#11172)
* 11171 fix graphql related models query

* 11171 remove redundant code from code review

* 11171 remove redundant code from code review
2022-12-14 11:32:22 -05:00
jeremystretch
80ced6b782 Closes #11163: Auto-detect data format during bulk import 2022-12-14 10:09:59 -05:00
jeremystretch
47dfb89c52 Relocate ImportFormatChoices 2022-12-14 09:30:10 -05:00
jeremystretch
064e3ff605 Merge branch 'develop' into feature 2022-12-13 17:17:05 -05:00
Jeremy Stretch
fb27803ab0 Merge pull request #11174 from netbox-community/develop
Release v3.3.10
2022-12-13 15:44:42 -05:00
jeremystretch
5e32b39f25 Release v3.3.10 2022-12-13 15:29:07 -05:00
jeremystretch
b9888d6f86 Fixes #11109: Fix nullification of custom object & multi-object fields via REST API 2022-12-13 14:48:40 -05:00
jeremystretch
96a796ebde Fixes #11173: Enable missing tags columns for contact, L2VPN lists 2022-12-13 14:04:50 -05:00
jeremystretch
996e73d5d8 Fixes #10981: Fix release notes formatting 2022-12-13 13:26:41 -05:00
jeremystretch
5c969a8caf Changelog for #9361, #10447, #11077 2022-12-13 13:24:07 -05:00
jeremystretch
68faab8196 Fixes #11168: Honor RQ_DEFAULT_TIMEOUT config parameter when using Redis Sentinel 2022-12-13 13:22:28 -05:00
sleepinggenius2
b3693099dc Adds replication and adoption for module import (#9498)
* Adds replication and adoption for module import

* Moves common Module form clean logic to new class

* Adds tests for replication and adoption for module import

* Fix test

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 11:33:09 -05:00
Arthur Hanson
9bb9ac3dec 11077 use formatting for custom field date (#11143)
* 11077 use formatting for custom field date

* Apply configured date format to column render() method

Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-13 09:22:57 -05:00
kkthxbye-code
a57378e780 Add missing newline and change wording of InventoryItem validation 2022-12-13 08:58:57 -05:00
kkthxbye-code
41f631b65b Allow re-assigning inventoryitems to other devices 2022-12-13 08:58:57 -05:00
jeremystretch
2db668f5cc Clean up v3.4 release notes 2022-12-12 16:49:00 -05:00
jeremystretch
aacf606999 #10739: Collapse BaseView class 2022-12-12 16:32:55 -05:00
jeremystretch
e338f7cfe3 #10371: Fix API serializer representation for module status 2022-12-12 16:14:18 -05:00
jeremystretch
758030733c #8366: Misc cleanup 2022-12-12 15:27:37 -05:00
jeremystretch
ad78f9e075 #7961: Fix updating via import when custom fields are absent 2022-12-12 14:19:27 -05:00
jeremystretch
3468e8c8ae #9623: Misc cleanup 2022-12-12 12:56:38 -05:00
jeremystretch
13d39a28ce #7854: Misc cleanup 2022-12-12 12:34:05 -05:00
jeremystretch
8809fc949b Fixes #11154: Index VM interface MAC address and MTU for global search 2022-12-12 10:43:48 -05:00
jeremystretch
860805ba82 Closes #10255: Introduce LOGOUT_REDIRECT_URL config parameter to control redirection of user after logout 2022-12-09 17:08:07 -05:00
jeremystretch
1e0b024609 Closes #10516: Add vertical frame & cabinet rack types 2022-12-09 16:35:37 -05:00
jeremystretch
8486d47d17 Fixes #11142: Correct available choices for status under IP range filter form 2022-12-09 16:04:46 -05:00
jeremystretch
407365888a Closes #11089: Permit whitespace in MAC addresses 2022-12-09 16:00:11 -05:00
jeremystretch
2ad1db0c64 Tweak migration name 2022-12-09 15:07:10 -05:00
jeremystretch
83a0576ca4 #9072: Add weight parameter to influence ViewTab ordering 2022-12-09 14:50:13 -05:00
jeremystretch
0b100b8fc8 Closes #10675: Add max_weight field to track maximum load capacity for racks 2022-12-09 12:45:02 -05:00
jeremystretch
2b12138c41 Fix configuration key for WirelessLANStatusChoices 2022-12-09 10:45:02 -05:00
jeremystretch
97aa40f7a8 Closes #10371: Add operational status field for modules 2022-12-09 10:43:29 -05:00
jeremystretch
b2f34cec19 Fixes #10950: Fix validation of VDC primary IPs 2022-12-09 09:49:01 -05:00
jeremystretch
3bc9586b0c Changelog for #10945 2022-12-08 18:18:51 -05:00
Jeremy Stretch
4297c65f87 Closes #10945: Enable recurring execution of scheduled reports & scripts (#11096)
* Add interval to JobResult

* Accept a recurrence interval when executing scripts & reports

* Cleaned up jobs list display

* Schedule next job only if a reference start time can be determined

* Improve validation for scheduled jobs
2022-12-08 18:17:13 -05:00
jeremystretch
62b0f034e7 Changelog for #11022 2022-12-08 10:00:21 -05:00
jeremystretch
6ffd8aa320 Introduce constants for RQ queue names 2022-12-08 09:58:52 -05:00
kkthxbye-code
d53ddd611b Add any queues defined in QUEUE_MAPPINGS to RQ_QUEUES 2022-12-08 09:45:21 -05:00
kkthxbye-code
080a001118 Allow redefining internally used queues 2022-12-08 09:45:21 -05:00
jeremystretch
5a77791f9d Merge branch 'develop' into feature 2022-12-08 09:31:22 -05:00
jeremystretch
ab9c253310 Fixes #11128: Disable ordering changelog table by object to avoid exception 2022-12-08 09:00:02 -05:00
jeremystretch
35596ddcbc Closes #10806: Add warning to run deactivate prior to upgrade script 2022-12-08 09:00:02 -05:00
kkthxbye-code
0cacac82ee Disable sorting by object_repr on ObjectChangeTable 2022-12-08 08:44:11 -05:00
Jeremy Stretch
780997a568 Closes #11119: Enable filtering L2VPNs by slug 2022-12-06 15:48:22 -05:00
Jeremy Stretch
d2d60c0607 Fixes #11087: Fix background color of bottom banner content 2022-12-06 15:40:59 -05:00
Renato Almeida de Oliveira
d4d8d00d01 add distinct method to circuit_count 2022-12-06 15:19:35 -05:00
jeremystretch
cb52d9c84e Changelog for #11000, #11046 2022-12-02 13:02:13 -05:00
jeremystretch
db61e57893 Closes #11090: Add regular expression support to global search engine 2022-12-02 12:54:35 -05:00
Jeremy Stretch
52cf9086a5 Fixes #11046: Restrict length of indexed search values (#11076)
* Fixes #11046: Restrict length of indexed search values

* Reference constant in index declaration

* Remove index from CachedValue.value
2022-12-02 10:07:53 -05:00
jeremystretch
db7590df1a Changelog for #10748, #11041 2022-12-02 09:30:44 -05:00
PieterL75
ee03f3d584 10748 Add 'Provider' to the circuit termination edit/view (#10939)
* Show the Provider of the NetworkProvider

* Clean up form fields

Co-authored-by: Pieter Lambrecht <pieter.lambrecht@sentia.com>
Co-authored-by: jeremystretch <jstretch@ns1.com>
2022-12-02 09:27:47 -05:00
Arthur Hanson
2577f3a786 11000 improve yaml import (#11075)
* 11000 improve yaml import

* 11000 add commenting

* 11000 add commenting
2022-12-02 08:34:24 -05:00
Arthur
826a1714c3 11041 return power percentage with 1 decimal place 2022-12-01 15:41:15 -05:00
jeremystretch
d0e0c2ff8b Merge branch 'develop' into feature 2022-11-30 16:21:20 -05:00
jeremystretch
fb407e9076 PRVB 2022-11-30 16:18:03 -05:00
Jeremy Stretch
85c60670dc Merge pull request #11059 from netbox-community/develop
Release v3.3.9
2022-11-30 16:14:00 -05:00
jeremystretch
f2f36c67f6 Release v3.3.9 2022-11-30 15:51:37 -05:00
jeremystretch
281934cf34 Fixes #11047: Cloning a rack reservation should replicate rack & user 2022-11-30 15:37:50 -05:00
jeremystretch
00d72f18cf Annotate need for natural ordering 2022-11-30 15:33:01 -05:00
Arthur
b36afdc924 11014 code review changes 2022-11-30 15:33:01 -05:00
Arthur
4ed45e4031 11014 fix rack elevation name sorting 2022-11-30 15:33:01 -05:00
Arthur
cf0258204f 11048 fix connected_endpoint refs 2022-11-30 15:13:45 -05:00
Patrick Hurrelmann
3bd560add8 Fixes #11028 - Enable clearing of the color field for front and rear ports in bulk edit 2022-11-30 15:09:07 -05:00
Arthur
9e51a8d9d2 10999 fix power utilization on Device detail 2022-11-29 09:38:04 -05:00
Arthur
f59c6699f6 11025 fix error tag toast 2022-11-29 09:36:48 -05:00
jeremystretch
39732fa861 Merge branch 'develop' into feature 2022-11-29 09:19:11 -05:00
jeremystretch
80f5eeacdd Fix issues loading demo data 2022-11-29 09:18:03 -05:00
jeremystretch
f56e3eb784 Fixes #8058: Display server-side form errors inline with fields 2022-11-22 12:02:21 -05:00
jeremystretch
c3dcd8937f Merge branch 'develop' into feature 2022-11-22 10:08:23 -05:00
jeremystretch
b1da374df2 Fixes #10997: Fix exception when editing NAT IP for VM with no cluster 2022-11-22 08:52:21 -05:00
jeremystretch
dc1da0a738 Fixes #10996: Hide checkboxes on child object lists when no bulk operations are available 2022-11-22 08:52:04 -05:00
jeremystretch
1946e8f053 #10984: Update test 2022-11-21 16:05:51 -05:00
jeremystretch
4623858849 Fixes #10936: Permit demotion of device/VM primary IP via IP address edit form 2022-11-21 15:36:13 -05:00
jeremystretch
9c5891f1b6 Fixes #10929: Raise validation error when attempting to create a duplicate cable termination 2022-11-21 14:08:33 -05:00
jeremystretch
d5538c1ca3 Fixes #10241: Support referencing custom field related objects by attribute in addition to PK 2022-11-21 12:48:13 -05:00
jeremystretch
90f15b8d55 Fixes #10938: render_field template tag should respect label kwarg 2022-11-21 09:49:30 -05:00
jeremystretch
4e27e8d3dd Fixes #10969: Update cable paths ending at associated rear port when creating new front ports 2022-11-21 09:44:08 -05:00
jeremystretch
150cb772fe Fixes #10984: Fix navigation menu expansion for plugin menus comprising multiple words 2022-11-21 08:38:44 -05:00
jeremystretch
e494d7bb22 Fixes #10982: Catch NoReverseMatch exception when rendering tabs with no registered URL 2022-11-21 08:06:12 -05:00
jeremystretch
9774bb46ce Fixes #10973: Fix device links in VDC table 2022-11-18 16:33:06 -05:00
jeremystretch
84c0c45da9 Fixes #10980: Fix view tabs for plugin objects 2022-11-18 16:26:08 -05:00
jeremystretch
46e3883f19 Closes #815: Enable specifying terminations when bulk importing circuits 2022-11-18 15:22:24 -05:00
Arthur
3a89a676cd 10869 convert docstring to comment 2022-11-18 13:47:55 -05:00
jeremystretch
0885333b11 Fixes #9223: Fix serialization of array field values in change log 2022-11-18 11:24:14 -05:00
jeremystretch
c287641363 Changelog for #10236, #10653 2022-11-18 11:23:30 -05:00
Arthur Hanson
de9646d096 10653 log failed login attempts on INFO (#10843)
* 10653 log failed login attempts on INFO

* 10653 use signal to log failed login attempts

* 10653 use signal to log failed login attempts

* Update netbox/users/signals.py

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

* Update netbox/users/apps.py

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

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

* 10236 optimize with statement
2022-11-18 08:55:28 -05:00
jeremystretch
3a5914827b Fixes #6389: Call snapshot() on object when processing deletions 2022-11-17 21:04:55 -05:00
jeremystretch
cf55e96241 Fixes #10721: Disable ordering by custom object field columns 2022-11-17 16:30:54 -05:00
jeremystretch
bd29d15814 Fixes #10579: Mark cable traces terminating to a provider network as complete 2022-11-17 16:08:29 -05:00
jeremystretch
d3911e2a4c Fixes #9878: Fix spurious error message when rendering REST API docs 2022-11-17 15:13:37 -05:00
jeremystretch
eb591731ef #10712: Remove pin for swagger-spec-validator (fixed in v3.0.3) 2022-11-17 13:06:51 -05:00
jeremystretch
ae11419045 Changelog for #10946, #10948 2022-11-17 12:41:24 -05:00
jeremystretch
43bbd42d3c Fixes #10957: Add missing VDCs column to interface tables 2022-11-17 12:29:30 -05:00
jeremystretch
d4a231585a Clean up tests 2022-11-17 10:50:05 -05:00
kkthxbye-code
977b79ecee Check that device has a platform set before rendering napalm tab 2022-11-17 08:25:06 -05:00
kkthxbye-code
5202d0add9 Linkify primary IP for VDC 2022-11-17 08:22:42 -05:00
jeremystretch
ebf555e1fb Use strings to specify prerequisite models 2022-11-16 17:22:09 -05:00
jeremystretch
f411c4f439 Introduce panel template for services 2022-11-16 16:52:35 -05:00
jeremystretch
216d8d24b8 Add note to update model's SearchIndex 2022-11-16 16:40:01 -05:00
jeremystretch
cb2b256934 Fix typo 2022-11-16 16:38:29 -05:00
223 changed files with 3699 additions and 1725 deletions

View File

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

View File

@@ -24,7 +24,7 @@ jobs:
necessary.
close-pr-message: >
This PR has been automatically closed due to lack of activity.
days-before-stale: 60
days-before-stale: 90
days-before-close: 30
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
operations-per-run: 100

View File

@@ -1,188 +1,115 @@
## Getting Help
**Looking for help?** NetBox has a vast, active community of fellow users that may be able to provide assistance. Just [start a discussion](https://github.com/netbox-community/netbox/discussions/new) right here on GitHub! Or if you'd prefer to chat, join us live in the `#netbox` channel on the [NetDev Community Slack](https://netdev.chat/)!
If you encounter any issues installing or using NetBox, try one of the
following resources to get assistance. Please **do not** open a GitHub issue
except to report bugs or request features.
<div align="center">
<h3>
:bug: <a href="#bug-reporting-bugs">Report a bug</a> &middot;
:bulb: <a href="#bulb-feature-requests">Suggest a feature</a> &middot;
:arrow_heading_up: <a href="#arrow_heading_up-submitting-pull-requests">Submit a pull request</a>
</h3>
<h3>
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> &middot;
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> &middot;
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
</h3>
</div>
<h3></h3>
### GitHub Discussions
Some general tips for engaging here on GitHub:
GitHub's discussions are the best place to get help or propose rough ideas for
new functionality. Their integration with GitHub allows for easily cross-
referencing and converting posts to issues as needed. There are several
categories for discussions:
* Register for a free [GitHub account](https://github.com/signup) if you haven't already.
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
* **General** - General community discussion
* **Ideas** - Ideas for new functionality that isn't yet ready for a formal
feature request
* **Q&A** - Request help with installing or using NetBox
## :bug: Reporting Bugs
### Slack
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
Unfortunately, the Slack channel does not provide long-term retention of chat
history, so try to avoid it for any discussions would benefit from being
preserved for future reference.
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
## Reporting Bugs
* If you can't find any existing issues (open or closed) that seem to match yours, you're welcome to [submit a new bug report](https://github.com/netbox-community/netbox/issues/new?label=type%3A+bug&template=bug_report.yaml). Be sure to complete the entire report template, including detailed steps that someone triaging your issue can follow to confirm the reported behavior. (If we're not able to replicate the bug based on the information provided, we'll ask for additional detail.)
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases)
of NetBox. If you're running an older version, it's possible that the bug has
already been fixed.
* Next, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
to see if the bug you've found has already been reported. If you think you may
be experiencing a reported issue that hasn't already been resolved, please
click "add a reaction" in the top right corner of the issue and add a thumbs
up (+1). You might also want to add a comment describing how it's affecting your
installation. This will allow us to prioritize bugs based on how many users are
affected.
* When submitting an issue, please be as descriptive as possible. Be sure to
provide all information request in the issue template, including:
* The environment in which NetBox is running
* The exact steps that can be taken to reproduce the issue
* Expected and observed behavior
* Any error messages generated
* Screenshots (if applicable)
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
The issue will be reviewed by a maintainer after submission and the appropriate
labels will be applied for categorization.
* Keep in mind that we prioritize bugs based on their severity and how much
work is required to resolve them. It may take some time for someone to address
your issue.
* Some other tips to keep in mind:
* Error messages and screenshots are especially helpful.
* Don't prepend your issue title with a label like `[Bug]`; the proper label will be assigned automatically.
* Ensure that your reproduction instructions don't reference data in our [demo instance](https://demo.netbox.dev/), which gets rebuilt nightly.
* Verify that you have GitHub notifications enabled and are subscribed to your issue after submitting.
* We appreciate your patience as bugs are prioritized by their severity, impact, and difficulty to resolve.
* For more information on how bug reports are handled, please see our [issue
intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
## Feature Requests
## :bulb: Feature Requests
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
to see if the feature you're requesting is already listed. (Be sure to search
closed issues as well, since some feature requests have been rejected.) If the
feature you'd like to see has already been requested and is open, click "add a
reaction" in the top right corner of the issue and add a thumbs up (+1). This
ensures that the issue has a better chance of receiving attention. Also feel
free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the feature you have in mind has already been proposed. If you happen to find an open feature request that matches your idea, click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This ensures that the issue has a better chance of receiving attention. Also feel free to add a comment with any additional justification for the feature.
* Before filing a new feature request, consider raising your idea in a
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
first. Feedback you receive there will help validate and shape the proposed
feature before filing a formal issue.
* If you have a rough idea that's not quite ready for formal submission yet, start a [GitHub discussion](https://github.com/netbox-community/netbox/discussions) instead. This is a great way to test the viability and narrow down the scope of a new feature prior to submitting a formal proposal, and can serve to generate interest in your idea from other community members.
* Good feature requests are very narrowly defined. Be sure to thoroughly
describe the functionality and data model(s) being proposed. The more effort
you put into writing a feature request, the better its chance is of being
implemented. Overly broad feature requests will be closed.
* Once you're ready, submit a feature request [using this template](https://github.com/netbox-community/netbox/issues/new?label=type%3A+feature&template=feature_request.yaml). Be sure to provide sufficient context and detail to convey exactly what you're proposing and why. The stronger your use case, the better chance your proposal has of being accepted.
* When submitting a feature request on GitHub, be sure to include all
information requested by the issue template, including:
* Some other tips to keep in mind:
* Don't prepend your issue title with a label like `[Feature]`; the proper label will be assigned automatically.
* Try to anticipate any likely questions about your proposal and provide that information proactively.
* Verify that you have GitHub notifications enabled and are subscribed to your issue after submitting.
* You're welcome to volunteer to implement your FR, but don't submit a pull request until it has been approved.
* A detailed description of the proposed functionality
* A use case for the feature; who would use it and what value it would add
to NetBox
* A rough description of changes necessary to the database schema (if
applicable)
* Any third-party libraries or other resources which would be involved
* For more information on how feature requests are handled, please see our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
* Please avoid prepending any sort of tag (e.g. "[Feature]") to the issue
title. The issue will be reviewed by a moderator after submission and the
appropriate labels will be applied for categorization.
## :arrow_heading_up: Submitting Pull Requests
* For more information on how feature requests are handled, please see our
[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
* [Pull requests](https://docs.github.com/en/pull-requests) (a feature of GitHub) are used to propose changes to NetBox's code base. Our process generally goes like this:
* A user opens a new issue (bug report or feature request)
* A maintainer triages the issue and may mark it as needing an owner
* The issue's author can volunteer to own it, or someone else can
* A maintainer assigns the issue to whomever volunteers
* The issue owner submits a pull request that will resolve the issue
* A maintainer reviews and merges the pull request, closing the issue
## Submitting Pull Requests
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
* If you're interested in contributing to NetBox, be sure to check out our
[getting started](https://docs.netbox.dev/en/stable/development/getting-started/)
documentation for tips on setting up your development environment.
* New pull requests should generally be based off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing development, while `master` is used for tracking stable releases. (If you're developing for an upcoming minor release, use `feature` instead.)
* 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.
* 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 resulting from multiple PRs being submitted simultaneously.)
* 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. 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 **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. (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
resulting from multiple PRs being submitted simultaneously.)
* All code submissions should meet the following criteria (CI will enforce
these checks):
* Python syntax is valid
* All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be
* All code submissions should meet the following criteria (CI will enforce these checks):
* Python syntax is valid
* All tests pass when run with `./manage.py test`
* PEP 8 compliance is enforced, with the exception that lines may be
greater than 80 characters in length
## Commenting
* Some other tips to keep in mind:
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
* All new functionality must include relevant tests where applicable.
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 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.
## :jigsaw: Creating Plugins
## Issue Lifecycle
Do you have an idea for something you'd like to build in NetBox, but might not be right for the core project? NetBox includes a powerful and extensive [plugins framework](https://docs.netbox.dev/en/stable/plugins/) that enables users to develop their own custom data models and integrations.
New issues are handled according to our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
Maintainers will assign label(s) and/or close new issues as the policy
dictates. This helps ensure a productive development environment and avoid
accumulating a large backlog of work.
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
to aid in issue management.
## :rescue_worker_helmet: Become a Maintainer
* Issues will be marked as stale after 60 days of no activity.
* If the stable label is not removed in the following 30 days, the issue will
be closed automatically.
* Any issue bearing one of the following labels will be exempt from all Stale
bot actions:
* `status: accepted`
* `status: blocked`
* `status: needs milestone`
We're always looking for motivated individuals to join the maintainers team and help drive NetBox's long-term development. Some of our most sought-after skills include:
It is natural that some new issues get more attention than others. The stale
bot helps bring renewed attention to potentially valuable issues that may have
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
effort to circumvent the bot: This will result in the immediate closure of the
issue, and you may be barred from participating in future discussions.
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
* Expertise working with PostgreSQL databases
* Javascript & TypeScript proficiency
* A knack for web application design (HTML & CSS)
* Familiarity with git and software development best practices
* Excellent attention to detail
* Working experience in the field of network operations & engineering
## Maintainer Guidance
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
* Maintainers are expected to contribute at least four hours per week to the
project on average. This can be employer-sponsored or individual time, with
the understanding that all contributions are submitted under the Apache 2.0
license and that your employer may not make claim to any contributions.
Contributions include code work, issue management, and community support. All
development must be in accordance with our [development guidance](https://docs.netbox.dev/en/stable/development/).
Many maintainers petition their employer to grant some of their paid time to work on NetBox. In doing so, your employer becomes eligible to be featured as a [NetBox sponsor](https://github.com/netbox-community/netbox/wiki/Sponsorship).
* Maintainers are expected to attend (where feasible) our biweekly ~30-minute
sync to review agenda items. This meeting provides opportunity to present and
discuss pressing topics. Meetings are held as virtual audio/video conferences.
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
* Maintainers with no substantial recorded activity in a 60-day period will be
removed from the project.
## :heart: Other Ways to Contribute
You don't have to be a developer to contribute to NetBox: There are plenty of other ways you can add value to the community! Below are just a few examples:
* Help answer questions and provide feedback in our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) and on [Slack](https://netdev.chat/).
* Write a blog article or record a YouTube video demonstrating how NetBox is used at your organization.
* Help grow our [library of device & module type definitions](https://github.com/netbox-community/devicetype-library).

129
README.md
View File

@@ -1,107 +1,73 @@
<div align="center">
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
The premiere source of truth powering network automation
</div>
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is the leading solution for modeling and documenting modern networks. By
combining the traditional disciplines of IP address management (IPAM) and
datacenter infrastructure management (DCIM) with powerful APIs and extensions,
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.
Available as open source software under the Apache 2.0 license, NetBox serves
as the cornerstone for network automation in thousands of organizations.
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
* **Organization:** Manage tenant and contact assignments natively.
* **Powerful search:** Easily find anything you need using a single global search function.
* **Comprehensive logging:** Leverage both automatic change logging and user-submitted journal entries to track your network's growth over time.
* **Endless customization:** Custom fields, custom links, tags, export templates, custom validation, reports, scripts, and more!
* **Flexible permissions:** An advanced permissions systems enables very flexible delegation of permissions.
* **Integrations:** Easily connect NetBox to your other tooling via its REST & GraphQL APIs.
* **Plugins:** Not finding what you need in the core application? Try one of many community plugins - or build your own!
[![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)
![Screenshot of NetBox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
## About NetBox
## Getting Started
![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI")
* Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
* Choose your deployment: [self-hosted](https://github.com/netbox-community/netbox), [Docker](https://github.com/netbox-community/netbox-docker), or [NetBox Cloud](https://netboxlabs.com/netbox-cloud/).
* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
Myriad infrastructure components can be modeled in NetBox, including:
## Get Involved
* Hierarchical regions, site groups, sites, and locations
* Racks, devices, and device components
* Cables and wireless connections
* Power distribution
* Data circuits and providers
* 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
* Organizational tenants and contacts
* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
* Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
* Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
* Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.
In addition to its extensive built-in models and functionality, NetBox can be
customized and extended through the use of:
* Custom fields
* Custom links
* Configuration contexts
* Custom model validation rules
* Reports
* Custom scripts
* Export templates
* Conditional webhooks
* Plugins
* Single sign-on (SSO) authentication
* NAPALM integration
* Detailed change logging
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).
## Project Stats
<div align="center">
<h3>Thank you to our sponsors!</h3>
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
## Sponsors
<div align="center">
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com/)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com/)
[![NS1](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/ns1.png)](https://ns1.com)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io/)
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Stellar Technologies](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/stellar.png)](https://stellar.tech/)
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
</div>
### Discussion
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
### Installation
Please see [the documentation](https://docs.netbox.dev/) for
instructions on installing NetBox. To upgrade NetBox, please download the
[latest release](https://github.com/netbox-community/netbox/releases) and
run `upgrade.sh`.
### Providing Feedback
The best platform for general feedback, assistance, and other discussion is our
[GitHub discussions](https://github.com/netbox-community/netbox/discussions).
To report a bug or request a specific feature, please open a GitHub issue using
the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
### Screenshots
## Screenshots
![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)")
@@ -110,8 +76,3 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
![Screenshot of prefixes hierarchy](docs/media/screenshots/prefixes-list.png "Prefixes hierarchy")
![Screenshot of cable trace](docs/media/screenshots/cable-trace.png "Cable tracing")
### Related projects
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions)
for a list of relevant community projects.

View File

@@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
If you believe that you've found a vulnerability which meets all of these conditions, please email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
### Bug Bounties

View File

@@ -1,6 +1,6 @@
# HTML sanitizer
# https://github.com/mozilla/bleach
bleach
bleach<6.0
# The Python web framework on which NetBox is built
# https://github.com/django/django
@@ -68,7 +68,7 @@ drf-yasg[validation]
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django
graphene_django<3.0
graphene_django
# WSGI HTTP server
# https://gunicorn.org/

View File

@@ -1,4 +1,4 @@
# The IP address (typically localhost) and port that the Netbox WSGI process should listen on
# The IP address (typically localhost) and port that the NetBox WSGI process should listen on
bind = '127.0.0.1:8001'
# Number of gunicorn workers to spawn. This should typically be 2n+1, where

View File

@@ -5,6 +5,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.

View File

@@ -58,9 +58,11 @@ The following model fields support configurable choices:
* `circuits.Circuit.status`
* `dcim.Device.status`
* `dcim.Location.status`
* `dcim.Module.status`
* `dcim.PowerFeed.status`
* `dcim.Rack.status`
* `dcim.Site.status`
* `dcim.VirtualDeviceContext.status`
* `extras.JournalEntry.kind`
* `ipam.IPAddress.status`
* `ipam.IPRange.status`
@@ -68,6 +70,7 @@ The following model fields support configurable choices:
* `ipam.VLAN.status`
* `virtualization.Cluster.status`
* `virtualization.VirtualMachine.status`
* `wireless.WirelessLAN.status`
The following colors are supported:

View File

@@ -69,6 +69,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
---
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
Default: `2621440` (2.5 MB).
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
---
## GRAPHQL_ENABLED
!!! tip "Dynamic Configuration Parameter"
@@ -141,6 +149,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
## QUEUE_MAPPINGS
Allows changing which queues are used internally for background tasks.
```python
QUEUE_MAPPINGS = {
'webhook': 'low',
'report': 'high',
'script': 'high',
}
```
If no queue is defined the queue named `default` will be used.
---
## RELEASE_CHECK_URL
Default: None (disabled)

View File

@@ -63,6 +63,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `USERNAME` - Redis username (if set)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID
* `SSL` - Use SSL connection to Redis
@@ -75,6 +76,7 @@ REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'USERNAME': 'netbox'
'PASSWORD': 'foobar',
'DATABASE': 0,
'SSL': False,
@@ -82,6 +84,7 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'USERNAME': ''
'PASSWORD': '',
'DATABASE': 1,
'SSL': False,

View File

@@ -137,6 +137,14 @@ The lifetime (in seconds) of the authentication cookie issued to a NetBox user u
---
## LOGOUT_REDIRECT_URL
Default: `'home'`
The view name or URL to which a user is redirected after logging out.
---
## SESSION_COOKIE_NAME
Default: `sessionid`

View File

@@ -12,6 +12,17 @@ BASE_PATH = 'netbox/'
---
## DEFAULT_LANGUAGE
Default: `en-us` (US English)
Defines the default preferred language/locale for requests that do not specify one. This is used to alter e.g. the display of dates and numbers to fit the user's locale. See [this list](http://www.i18nguy.com/unicode/language-identifiers.html) of standard language codes. (This parameter maps to Django's [`LANGUAGE_CODE`](https://docs.djangoproject.com/en/stable/ref/settings/#language-code) internal setting.)
!!! note
Altering this parameter will *not* change the language used in NetBox. We hope to provide translation support in a future NetBox release.
---
## DOCS_ROOT
Default: `$INSTALL_ROOT/docs/`
@@ -54,6 +65,14 @@ Email is sent from NetBox only for critical events or if configured for [logging
---
## ENABLE_LOCALIZATION
Default: False
Determines if localization features are enabled or not. This should only be enabled for development or testing purposes as netbox is not yet fully localized. Turning this on will localize numeric and date formats (overriding what is set for DATE_FORMAT) based on the browser locale as well as translate certain strings from third party modules.
---
## HTTP_PROXIES
Default: None

View File

@@ -142,6 +142,19 @@ obj.full_clean()
obj.save()
```
## Error handling
Sometimes things go wrong and a script will run into an `Exception`. If that happens and an uncaught exception is raised by the custom script, the execution is aborted and a full stack trace is reported.
Although this is helpful for debugging, in some situations it might be required to cleanly abort the execution of a custom script (e.g. because of invalid input data) and thereby make sure no changes are performed on the database. In this case the script can throw an `AbortScript` exception, which will prevent the stack trace from being reported, but still terminating the script's execution and reporting a given error message.
```python
from utilities.exceptions import AbortScript
if some_error:
raise AbortScript("Some meaningful error message")
```
## Variable Reference
### Default Options

View File

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

View File

@@ -54,15 +54,19 @@ Each model should have a corresponding FilterSet class defined. This is used to
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
## 9. Create the object template
## 9. Create a SearchIndex subclass
If this model will be included in global search results, create a subclass of `netbox.search.SearchIndex` for it and specify the fields to be indexed.
## 10. Create the object template
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
## 10. Add the model to the navigation menu
## 11. Add the model to the navigation menu
Add the relevant navigation menu items in `netbox/netbox/navigation/menu.py`.
## 11. REST API components
## 12. REST API components
Create the following for each model:
@@ -71,13 +75,13 @@ Create the following for each model:
* API view in `api/views.py`
* Endpoint route in `api/urls.py`
## 12. GraphQL API components
## 13. GraphQL API components
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
## 13. Add tests
## 14. Add tests
Add tests for the following:
@@ -85,7 +89,7 @@ Add tests for the following:
* API views
* Filter sets
## 14. Documentation
## 15. Documentation
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.

View File

@@ -56,11 +56,15 @@ If the new field should be filterable, add it to the `FilterSet` for the model.
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
## 8. Update the UI templates
## 8. Update the SearchIndex
Where applicable, add the new field to the model's SearchIndex for inclusion in global search.
## 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 9. Create/extend test cases
## 10. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
@@ -72,6 +76,6 @@ Create or extend the relevant test cases to verify that the new field and any ac
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.
## 10. Update the model's documentation
## 11. Update the model's documentation
Each model has a dedicated page in the documentation, at `models/<app>/<model>.md`. Update this file to include any relevant information about the new field.

View File

@@ -52,4 +52,4 @@ NetBox is built on the enormously popular [Django](http://www.djangoproject.com/
* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
* The [installation guide](./installation/index.md) will help you get your own deployment up and running
* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1
* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)

View File

@@ -225,6 +225,9 @@ Once NetBox has been configured, we're ready to proceed with the actual installa
* Builds the documentation locally (for offline use)
* Aggregate static resource files on disk
!!! warning
If you still have a Python virtual environment active from a previous installation step, disable it now by running the `deactivate` command. This will avoid errors on systems where `sudo` has been configured to preserve the user's current environment.
```no-highlight
sudo /opt/netbox/upgrade.sh
```
@@ -269,7 +272,10 @@ See the [housekeeping documentation](../administration/housekeeping.md) for furt
## Test the Application
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance:
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.
!!! tip
Check that the Python virtual environment is still active before attempting to run the server.
```no-highlight
python3 manage.py runserver 0.0.0.0:8000 --insecure

View File

@@ -14,7 +14,10 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon.
!!! warning "Check user & group assignment"
The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly.
```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/

View File

@@ -4,7 +4,7 @@
NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016.
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation.
Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. Today, the open source project is stewarded by [NetBox Labs](https://netboxlabs.com/) and a team of volunteer maintainers. Beyond the core product, myriad [plugins](https://netbox.dev/plugins/) have been developed by the NetBox community to enhance and expand its feature set.
## Key Features
@@ -17,6 +17,7 @@ NetBox was built specifically to serve the needs of network engineers and operat
* AS number (ASN) management
* Rack elevations with SVG rendering
* Device modeling using pre-defined types
* Virtual chassis and device contexts
* Network, power, and console cabling with SVG traces
* Power distribution modeling
* Data circuit and provider tracking
@@ -29,12 +30,13 @@ NetBox was built specifically to serve the needs of network engineers and operat
* Tenant ownership assignment
* Device & VM configuration contexts for advanced configuration rendering
* Custom fields for data model extension
* Support for custom validation rules
* Custom validation rules
* Custom reports & scripts executable directly within the UI
* Extensive plugin framework for adding custom functionality
* Single sign-on (SSO) authentication
* Robust object-based permissions
* Detailed, automatic change logging
* Global search engine
* NAPALM integration
## What NetBox Is Not

View File

@@ -18,6 +18,13 @@ The [module bay](./modulebay.md) into which the module is installed.
The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module.
### Status
The module's operational status.
!!! tip
Additional statuses may be defined by setting `Module.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Serial Number
The unique physical serial number assigned to this module by its manufacturer.

View File

@@ -73,6 +73,10 @@ The maximum depth of a mounted device that the rack can accommodate, in millimet
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.)
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

View File

@@ -1,6 +1,6 @@
# Branches
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be mered by executing its `commit()` method. Deleting a branch will delete all its related changes.
A branch is a collection of related [staged changes](./stagedchange.md) that have been prepared for merging into the active database. A branch can be merged by executing its `commit()` method. Deleting a branch will delete all its related changes.
## Fields

View File

@@ -40,8 +40,8 @@ is represented in JSON as
```json
{
'tag': ['alpha', 'bravo'],
'status': 'active',
'region_id': 51
"tag": ["alpha", "bravo"],
"status": "active",
"region_id": 51
}
```

View File

@@ -23,7 +23,7 @@ The IPv4 or IPv6 address and mask, in CIDR notation (e.g. `192.0.2.0/24`).
The operational status of the IP address.
!!! tip
Additional statuses may be defined by setting `IPAddress.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
Additional statuses may be defined by setting `ipam.IPAddress.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.
### Role

View File

@@ -51,7 +51,7 @@ menu_items = (item1, item2, item3)
Each menu item represents a link and (optionally) a set of buttons comprising one entry in NetBox's navigation menu. Menu items are defined as PluginMenuItem instances. An example is shown below.
```python filename="navigation.py"
```python title="navigation.py"
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices

View File

@@ -97,7 +97,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
### Examples
`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied.
`status` is "active" and `primary_ip4` is defined _or_ the "exempt" tag is applied.
```json
{
@@ -109,8 +109,8 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
"value": "active"
},
{
"attr": "primary_ip",
"value": "",
"attr": "primary_ip4",
"value": null,
"negate": true
}
]

View File

@@ -168,7 +168,7 @@ Some text to show that the reference links can follow later.
## Images
```
Here's the Netbox logo (hover to see the title text):
Here's the NetBox logo (hover to see the title text):
Inline-style:
![alt text](/static/netbox_logo.png "Logo Title Text 1")
@@ -179,7 +179,7 @@ Reference-style:
[logo]: /static/netbox_logo.png "Logo Title Text 2"
```
Here's the Netbox logo (hover to see the title text):
Here's the NetBox logo (hover to see the title text):
Inline-style:
![alt text](/static/netbox_logo.png "Logo Title Text 1")

View File

@@ -10,6 +10,16 @@ Minor releases are published in April, August, and December of each calendar yea
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.4](./version-3.4.md) (December 2022)
* New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
* Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
* Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
* JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
* Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
* Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
* API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
#### [Version 3.3](./version-3.3.md) (August 2022)
* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102))

View File

@@ -1,6 +1,55 @@
# NetBox v3.3
## v3.3.9 (FUTURE)
## v3.3.10 (2022-12-13)
### Enhancements
* [#9361](https://github.com/netbox-community/netbox/issues/9361) - Add replication controls for module bulk import
* [#10255](https://github.com/netbox-community/netbox/issues/10255) - Introduce `LOGOUT_REDIRECT_URL` config parameter to control redirection of user after logout
* [#10447](https://github.com/netbox-community/netbox/issues/10447) - Enable reassigning an inventory item from one device to another
* [#10516](https://github.com/netbox-community/netbox/issues/10516) - Add vertical frame & cabinet rack types
* [#10748](https://github.com/netbox-community/netbox/issues/10748) - Add provider selection field for provider networks to circuit termination edit view
* [#11089](https://github.com/netbox-community/netbox/issues/11089) - Permit whitespace in MAC addresses
* [#11119](https://github.com/netbox-community/netbox/issues/11119) - Enable filtering L2VPNs by slug
### Bug Fixes
* [#11041](https://github.com/netbox-community/netbox/issues/11041) - Correct power utilization percentage precision
* [#11077](https://github.com/netbox-community/netbox/issues/11077) - Honor configured date format when displaying date custom field values in tables
* [#11087](https://github.com/netbox-community/netbox/issues/11087) - Fix background color of bottom banner content
* [#11101](https://github.com/netbox-community/netbox/issues/11101) - Correct circuits count under site view
* [#11109](https://github.com/netbox-community/netbox/issues/11109) - Fix nullification of custom object & multi-object fields via REST API
* [#11128](https://github.com/netbox-community/netbox/issues/11128) - Disable ordering changelog table by object to avoid exception
* [#11142](https://github.com/netbox-community/netbox/issues/11142) - Correct available choices for status under IP range filter form
* [#11168](https://github.com/netbox-community/netbox/issues/11168) - Honor `RQ_DEFAULT_TIMEOUT` config parameter when using Redis Sentinel
* [#11173](https://github.com/netbox-community/netbox/issues/11173) - Enable missing tags columns for contact, L2VPN lists
---
## v3.3.9 (2022-11-30)
### Enhancements
* [#10653](https://github.com/netbox-community/netbox/issues/10653) - Ensure logging of failed login attempts
### Bug Fixes
* [#6389](https://github.com/netbox-community/netbox/issues/6389) - Call `snapshot()` on object when processing deletions
* [#9223](https://github.com/netbox-community/netbox/issues/9223) - Fix serialization of array field values in change log
* [#9878](https://github.com/netbox-community/netbox/issues/9878) - Fix spurious error message when rendering REST API docs
* [#10236](https://github.com/netbox-community/netbox/issues/10236) - Fix TypeError exception when viewing PDU configured for three-phase power
* [#10241](https://github.com/netbox-community/netbox/issues/10241) - Support referencing custom field related objects by attribute in addition to PK
* [#10579](https://github.com/netbox-community/netbox/issues/10579) - Mark cable traces terminating to a provider network as complete
* [#10721](https://github.com/netbox-community/netbox/issues/10721) - Disable ordering by custom object field columns
* [#10929](https://github.com/netbox-community/netbox/issues/10929) - Raise validation error when attempting to create a duplicate cable termination
* [#10936](https://github.com/netbox-community/netbox/issues/10936) - Permit demotion of device/VM primary IP via IP address edit form
* [#10938](https://github.com/netbox-community/netbox/issues/10938) - `render_field` template tag should respect `label` kwarg
* [#10969](https://github.com/netbox-community/netbox/issues/10969) - Update cable paths ending at associated rear port when creating new front ports
* [#10996](https://github.com/netbox-community/netbox/issues/10996) - Hide checkboxes on child object lists when no bulk operations are available
* [#10997](https://github.com/netbox-community/netbox/issues/10997) - Fix exception when editing NAT IP for VM with no cluster
* [#11014](https://github.com/netbox-community/netbox/issues/11014) - Use natural ordering when sorting rack elevations by name
* [#11028](https://github.com/netbox-community/netbox/issues/11028) - Enable bulk clearing of color attribute of pass-through ports
* [#11047](https://github.com/netbox-community/netbox/issues/11047) - Cloning a rack reservation should replicate rack & user
---
@@ -429,7 +478,7 @@ Custom field UI visibility has no impact on API operation.
* The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned.
* Added the optional `device` field
* Added the `l2vpn_termination` read-only field
wireless.WirelessLAN
* wireless.WirelessLAN
* Added `tenant` field
wireless.WirelessLink
* wireless.WirelessLink
* Added `tenant` field

View File

@@ -1,22 +1,152 @@
# NetBox v3.4
## v3.4.5 (2023-02-21)
### Enhancements
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges
* [#11592](https://github.com/netbox-community/netbox/issues/11592) - Introduce `FILE_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
* [#11685](https://github.com/netbox-community/netbox/issues/11685) - Match on containing prefixes and aggregates when querying for IP addresses using global search
* [#11787](https://github.com/netbox-community/netbox/issues/11787) - Upgrade script will automatically rebuild missing search cache
### Bug Fixes
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation
* [#11226](https://github.com/netbox-community/netbox/issues/11226) - Ensure scripts and reports within submodules are automatically reloaded
* [#11459](https://github.com/netbox-community/netbox/issues/11459) - Enable evaluating null values in custom validation rules
* [#11473](https://github.com/netbox-community/netbox/issues/11473) - GraphQL requests specifying an invalid filter should return an empty queryset
* [#11582](https://github.com/netbox-community/netbox/issues/11582) - Ensure form validation errors are displayed when adding virtual chassis members
* [#11601](https://github.com/netbox-community/netbox/issues/11601) - Fix partial matching of start/end addresses for IP range search
* [#11683](https://github.com/netbox-community/netbox/issues/11683) - Fix CSV header attribute detection when auto-detecting import format
* [#11711](https://github.com/netbox-community/netbox/issues/11711) - Fix CSV import for multiple-object custom fields
* [#11723](https://github.com/netbox-community/netbox/issues/11723) - Circuit terminations should link to their associated circuits (rather than site or provider network)
* [#11775](https://github.com/netbox-community/netbox/issues/11775) - Skip checking for old search cache records when creating a new object
* [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
---
## v3.4.4 (2023-02-02)
### Enhancements
* [#10762](https://github.com/netbox-community/netbox/issues/10762) - Permit selection custom fields to have only one choice
* [#11152](https://github.com/netbox-community/netbox/issues/11152) - Introduce AbortScript exception to elegantly abort scripts
* [#11554](https://github.com/netbox-community/netbox/issues/11554) - Add module types count to manufacturers list
* [#11585](https://github.com/netbox-community/netbox/issues/11585) - Add IP address filters for services
* [#11598](https://github.com/netbox-community/netbox/issues/11598) - Add buttons to easily switch between rack list and elevations views
### Bug Fixes
* [#11267](https://github.com/netbox-community/netbox/issues/11267) - Avoid catching ImportErrors when loading plugin resources
* [#11487](https://github.com/netbox-community/netbox/issues/11487) - Remove "set null" option from non-writable custom fields during bulk edit
* [#11491](https://github.com/netbox-community/netbox/issues/11491) - Show edit/delete buttons in user tokens table
* [#11528](https://github.com/netbox-community/netbox/issues/11528) - Permit import of devices using uploaded file
* [#11555](https://github.com/netbox-community/netbox/issues/11555) - Avoid inadvertent interpretation of search query as regular expression under global search (previously [#11516](https://github.com/netbox-community/netbox/issues/11516))
* [#11562](https://github.com/netbox-community/netbox/issues/11562) - Correct ordering of virtual chassis interfaces with duplicate names
* [#11574](https://github.com/netbox-community/netbox/issues/11574) - Fix exception when attempting to schedule reports/scripts
* [#11620](https://github.com/netbox-community/netbox/issues/11620) - Correct available filter choices for interface PoE type
* [#11635](https://github.com/netbox-community/netbox/issues/11635) - Pre-populate assigned VRF when following "first available IP" link from prefix view
* [#11650](https://github.com/netbox-community/netbox/issues/11650) - Display error message when attempting to create device component with duplicate name
---
## v3.4.3 (2023-01-20)
### Enhancements
* [#9996](https://github.com/netbox-community/netbox/issues/9996) - Introduce `CA_CERT_PATH` parameter to define SSL CA path for Redis servers
* [#10486](https://github.com/netbox-community/netbox/issues/10486) - Add a cable edit button for connected components in component lists
* [#11118](https://github.com/netbox-community/netbox/issues/11118) - Add L2VPN filters for VLANs and interfaces
* [#11150](https://github.com/netbox-community/netbox/issues/11150) - Add primary IPv4/v6 address filters for devices
* [#11227](https://github.com/netbox-community/netbox/issues/11227) - Add 800GE interface types
* [#11228](https://github.com/netbox-community/netbox/issues/11228) - List both devices & VMs under device role view
* [#11245](https://github.com/netbox-community/netbox/issues/11245) - Enable export templates for journal entries
* [#11371](https://github.com/netbox-community/netbox/issues/11371) - Introduce additional 100M Ethernet interface types
### Bug Fixes
* [#10201](https://github.com/netbox-community/netbox/issues/10201) - Fix AssertionError exception when removing some terminations from an existing cable
* [#11210](https://github.com/netbox-community/netbox/issues/11210) - Fix ValueError exception when attempting to bulk import cables attached to occupied terminations
* [#11340](https://github.com/netbox-community/netbox/issues/11340) - Avoid flagging cable termination changes erroneously
* [#11379](https://github.com/netbox-community/netbox/issues/11379) - Fix TypeError exception when bulk editing custom date fields
* [#11384](https://github.com/netbox-community/netbox/issues/11384) - Correct current time display on script & report forms
* [#11402](https://github.com/netbox-community/netbox/issues/11402) - Avoid LookupError exception when running scripts with commit disabled
* [#11403](https://github.com/netbox-community/netbox/issues/11403) - Fix exception when scheduling a job in the past
* [#11416](https://github.com/netbox-community/netbox/issues/11416) - Avoid AttributeError exception when deleting a cabled circuit termination
* [#11433](https://github.com/netbox-community/netbox/issues/11433) - Avoid AttributeError exception when generating API schema for views with custom schema
* [#11438](https://github.com/netbox-community/netbox/issues/11438) - Fix deletion of scheduled job using non-default queues
* [#11444](https://github.com/netbox-community/netbox/issues/11444) - Adding/removing a device from a device bay should record a pre-change snapshot on the device bay
* [#11467](https://github.com/netbox-community/netbox/issues/11467) - Correct count on interfaces tab when viewing a VC master device
* [#11483](https://github.com/netbox-community/netbox/issues/11483) - Apply configured formatting to custom date fields
* [#11488](https://github.com/netbox-community/netbox/issues/11488) - Add missing `description` fields to several REST API serializers
* [#11497](https://github.com/netbox-community/netbox/issues/11497) - Enforce `run_script` permission when executing scripts via REST API
* ~[#11516](https://github.com/netbox-community/netbox/issues/11516) - Prevent text highlight utility from interpreting match as regex~
* [#11522](https://github.com/netbox-community/netbox/issues/11522) - Correct tag links under contact & tenant list views
* [#11537](https://github.com/netbox-community/netbox/issues/11537) - Remove obsolete "Connection" column from power feeds table
* [#11544](https://github.com/netbox-community/netbox/issues/11544) - Catch ValidationError exception when filtering by invalid MAC address
---
## v3.4.2 (2023-01-03)
### Enhancements
* [#9285](https://github.com/netbox-community/netbox/issues/9285) - Enable specifying assigned component during bulk import of inventory items
* [#10700](https://github.com/netbox-community/netbox/issues/10700) - Match device name when using modules quick search
* [#11121](https://github.com/netbox-community/netbox/issues/11121) - Add VM resource totals to cluster view
* [#11156](https://github.com/netbox-community/netbox/issues/11156) - Enable selecting assigned component when editing inventory item in UI
* [#11223](https://github.com/netbox-community/netbox/issues/11223) - `reindex` management command should accept app label without model name
* [#11244](https://github.com/netbox-community/netbox/issues/11244) - Add controls for saved filters to rack elevations list
* [#11248](https://github.com/netbox-community/netbox/issues/11248) - Fix database migration when plugin with search indexer is enabled
* [#11259](https://github.com/netbox-community/netbox/issues/11259) - Add support for Redis username configuration
### Bug Fixes
* [#11280](https://github.com/netbox-community/netbox/issues/11280) - Fix errant newlines when exporting interfaces with multiple IP addresses assigned
* [#11290](https://github.com/netbox-community/netbox/issues/11290) - Correct reporting of scheduled job duration
* [#11232](https://github.com/netbox-community/netbox/issues/11232) - Enable partial & regular expression matching for non-string types in global search
* [#11342](https://github.com/netbox-community/netbox/issues/11342) - Correct cable trace URL under "connection" tab for device components
* [#11345](https://github.com/netbox-community/netbox/issues/11345) - Fix form validation for bulk import of modules
---
## v3.4.1 (2022-12-16)
### Enhancements
* [#9971](https://github.com/netbox-community/netbox/issues/9971) - Enable ordering of nested group models by name
* [#11214](https://github.com/netbox-community/netbox/issues/11214) - Introduce the `DEFAULT_LANGUAGE` configuration parameter
### Bug Fixes
* [#11175](https://github.com/netbox-community/netbox/issues/11175) - Fix cloning of fields containing special characters
* [#11178](https://github.com/netbox-community/netbox/issues/11178) - Pressing enter in quick search box should not trigger bulk operations
* [#11184](https://github.com/netbox-community/netbox/issues/11184) - Correct visualization of cable path which splits across multiple circuit terminations
* [#11185](https://github.com/netbox-community/netbox/issues/11185) - Fix TemplateSyntaxError when viewing custom script results
* [#11189](https://github.com/netbox-community/netbox/issues/11189) - Fix localization of dates & numbers
* [#11205](https://github.com/netbox-community/netbox/issues/11205) - Correct cloning behavior for recursively-nested models
* [#11206](https://github.com/netbox-community/netbox/issues/11206) - Avoid clearing assigned groups if `REMOTE_AUTH_DEFAULT_GROUPS` is invalid
---
## v3.4.0 (2022-12-14)
!!! warning "PostgreSQL 11 Required"
NetBox v3.4 requires PostgreSQL 11 or later.
### Breaking Changes
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" will raise a validation error.
* The `asn` field has been removed from the provider model. Please replicate any provider ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading.
* The `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading.
* The `content_type` field on the CustomLink and ExportTemplate models have been renamed to `content_types` and now supports the assignment of multiple content types.
* The `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, use `custom_field_data` instead.
* Device and virtual machine names are no longer case-sensitive. Attempting to create e.g. "device1" and "DEVICE1" within the same site will raise a validation error.
* The `asn`, `noc_contact`, `admin_contact`, and `portal_url` fields have been removed from the provider model. Please replicate any data remaining in these fields to the ASN and contact models introduced in NetBox v3.1 prior to upgrading.
* The `content_type` fields on the CustomLink and ExportTemplate models have been renamed to `content_types` and now support the assignment of multiple content types per object.
* Within the Python API, the `cf` property on an object with custom fields now returns deserialized values. For example, a custom field referencing an object will return the object instance rather than its numeric ID. To access the raw serialized values, reference the object's `custom_field_data` attribute instead.
* The `NetBoxModelCSVForm` class has been renamed to `NetBoxModelImportForm`. Backward compatability with the previous name has been retained for this release, but will be dropped in NetBox v3.5.
### New Features
#### New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560))
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much speedier, more intelligent search capability. Matches are returned in order of precedence regardless of object type, and matched field values are highlighted in the results. Additionally, custom field values are now included in global search results (when enabled). Plugins can also register their own models with the new global search engine.
NetBox's global search functionality has been completely overhauled and replaced by a new cache-based lookup. This new implementation provides a much faster, more intelligent search capability. Results are returned in order of precedence regardless of object type, and matching field values are highlighted in the results. Additionally, custom field values are now included in global search results (where enabled). Plugins can also register their own models with the new global search engine.
#### Virtual Device Contexts ([#7854](https://github.com/netbox-community/netbox/issues/7854))
@@ -24,19 +154,31 @@ A new model representing virtual device contexts (VDCs) has been added. VDCs are
#### Saved Filters ([#9623](https://github.com/netbox-community/netbox/issues/9623))
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse as a convenience. Saved filters can be kept private, or shared among NetBox users.
Object lists can be filtered by a variety of different fields and characteristics. Applied filters can now be saved for reuse. For example, the query string
### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
```
?status=active&region_id=12&tenant=acme
```
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most objects, has been extended to support the import of objects from JSON and/or YAML data as well.
can be saved and applied to future queries as
#### CSV-Based Bulk Updates ([#7961](https://github.com/netbox-community/netbox/issues/7961))
```
?filter=my-custom-filter
```
Saved filters can be kept private, or shared among NetBox users. They can be applied to both UI and REST API searches.
#### JSON/YAML Bulk Imports ([#4347](https://github.com/netbox-community/netbox/issues/4347))
NetBox's bulk import feature, which was previously limited to CSV-formatted data for most types of objects, has been extended to accept data formatted in JSON or YAML as well. This enables users to directly import objects from a variety of sources without needing to first convert data to CSV. NetBox will attempt to automatically determine the format of import data if not specified by the user.
#### Update Existing Objects via Bulk Import ([#7961](https://github.com/netbox-community/netbox/issues/7961))
NetBox's CSV-based bulk import functionality has been extended to support also modifying existing objects. When an `id` column is present in the import form, it will be used to infer the object to be modified, rather than a new object being created. All fields (columns) are optional when modifying existing objects.
#### Scheduled Reports & Scripts ([#8366](https://github.com/netbox-community/netbox/issues/8366))
Reports and custom scripts can now be scheduled for execution at a desired time.
Reports and custom scripts can now be scheduled for execution at a desired future time. Background scheduling is handled entirely by the existing RQ workers; there is no need to configure additional tasks to support scheduled jobs. When creating a scheduled job, the user may optionally specify an interval at which the job will run repeatedly (e.g. every 24 hours).
#### API for Staged Changes ([#10851](https://github.com/netbox-community/netbox/issues/10851))
@@ -47,6 +189,7 @@ This release introduces a new programmatic API that enables plugins and custom s
### Enhancements
* [#815](https://github.com/netbox-community/netbox/issues/815) - Enable specifying terminations when bulk importing circuits
* [#6003](https://github.com/netbox-community/netbox/issues/6003) - Enable the inclusion of custom field values in global search
* [#7376](https://github.com/netbox-community/netbox/issues/7376) - Enable the assignment of tags during CSV import
* [#8245](https://github.com/netbox-community/netbox/issues/8245) - Enable GraphQL filtering of related objects
@@ -59,23 +202,40 @@ This release introduces a new programmatic API that enables plugins and custom s
* [#9817](https://github.com/netbox-community/netbox/issues/9817) - Add `assigned_object` field to GraphQL type for IP addresses and L2VPN terminations
* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Add `mounting_depth` field to rack model
* [#9892](https://github.com/netbox-community/netbox/issues/9892) - Add optional `name` field for FHRP groups
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute now returns deserialized custom field data
* [#10348](https://github.com/netbox-community/netbox/issues/10348) - Add decimal custom field type
* [#10371](https://github.com/netbox-community/netbox/issues/10371) - Add `status` field for modules
* [#10545](https://github.com/netbox-community/netbox/issues/10545) - Standardize the use of `description` and `comments` fields on all primary models
* [#10556](https://github.com/netbox-community/netbox/issues/10556) - Include a `display` field in all GraphQL object types
* [#10595](https://github.com/netbox-community/netbox/issues/10595) - Add GraphQL relationships for additional generic foreign key fields
* [#10675](https://github.com/netbox-community/netbox/issues/10675) - Add `max_weight` field to track maximum load capacity for racks
* [#10698](https://github.com/netbox-community/netbox/issues/10698) - Omit app label from content type in table columns
* [#10710](https://github.com/netbox-community/netbox/issues/10710) - Add `status` field to WirelessLAN
* [#10761](https://github.com/netbox-community/netbox/issues/10761) - Enable associating an export template with multiple object types
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
* [#10945](https://github.com/netbox-community/netbox/issues/10945) - Enable recurring execution of scheduled reports & scripts
* [#11022](https://github.com/netbox-community/netbox/issues/11022) - Introduce `QUEUE_MAPPINGS` configuration parameter to allow customization of background task prioritization
### Bug Fixes (from v3.4-beta1)
* [#10946](https://github.com/netbox-community/netbox/issues/10946) - Fix AttributeError exception when viewing a device with a primary IP and no platform assigned
* [#10948](https://github.com/netbox-community/netbox/issues/10948) - Linkify primary IPs for VDCs
* [#10950](https://github.com/netbox-community/netbox/issues/10950) - Fix validation of VDC primary IPs
* [#10957](https://github.com/netbox-community/netbox/issues/10957) - Add missing VDCs column to interface tables
* [#10973](https://github.com/netbox-community/netbox/issues/10973) - Fix device links in VDC table
* [#10980](https://github.com/netbox-community/netbox/issues/10980) - Fix view tabs for plugin objects
* [#10982](https://github.com/netbox-community/netbox/issues/10982) - Catch `NoReverseMatch` exception when rendering tabs with no registered URL
* [#10984](https://github.com/netbox-community/netbox/issues/10984) - Fix navigation menu expansion for plugin menus comprising multiple words
* [#11000](https://github.com/netbox-community/netbox/issues/11000) - Improve validation of YAML-formatted import data
* [#11046](https://github.com/netbox-community/netbox/issues/11046) - Fix exception when caching very large field values for search
* [#11154](https://github.com/netbox-community/netbox/issues/11154) - Index VM interface MAC address and MTU for global search
* [#11171](https://github.com/netbox-community/netbox/issues/11171) - Fix querying of related objects under GraphQL API
### Plugins API
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Add `plugin_list_buttons` template tag to embed buttons on object lists
* [#4751](https://github.com/netbox-community/netbox/issues/4751) - Enable embedding custom content on core list views via `list_buttons()` method
* [#8927](https://github.com/netbox-community/netbox/issues/8927) - Enable inclusion of plugin models in global search via `SearchIndex`
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus
* [#9071](https://github.com/netbox-community/netbox/issues/9071) - Enable plugins to register top-level navigation menus using PluginMenu
* [#9072](https://github.com/netbox-community/netbox/issues/9072) - Enable registration of tabbed plugin views for core NetBox models
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps
* [#9880](https://github.com/netbox-community/netbox/issues/9880) - Enable plugins to install and register other Django apps via `django_apps` attribute
* [#9887](https://github.com/netbox-community/netbox/issues/9887) - Inspect `docs_url` property to determine link to model documentation
* [#10314](https://github.com/netbox-community/netbox/issues/10314) - Move `clone()` method from NetBoxModel to CloningMixin
* [#10543](https://github.com/netbox-community/netbox/issues/10543) - Introduce `get_plugin_config()` utility function
@@ -85,11 +245,13 @@ This release introduces a new programmatic API that enables plugins and custom s
* [#9045](https://github.com/netbox-community/netbox/issues/9045) - Remove legacy ASN field from provider model
* [#9046](https://github.com/netbox-community/netbox/issues/9046) - Remove legacy contact fields from provider model
* [#10052](https://github.com/netbox-community/netbox/issues/10052) - The `cf` attribute on objects now returns deserialized custom field data
* [#10358](https://github.com/netbox-community/netbox/issues/10358) - Raise minimum required PostgreSQL version from 10 to 11
* [#10694](https://github.com/netbox-community/netbox/issues/10694) - Emit the `post_save` signal when creating device components in bulk
* [#10697](https://github.com/netbox-community/netbox/issues/10697) - Move application registry into core app
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove custom `import_object()` function
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request when instantiating a FilterSet within UI views
* [#10699](https://github.com/netbox-community/netbox/issues/10699) - Remove unused custom `import_object()` function
* [#10781](https://github.com/netbox-community/netbox/issues/10781) - Add support for Python v3.11
* [#10816](https://github.com/netbox-community/netbox/issues/10816) - Pass the current request as context when instantiating a FilterSet within UI views
* [#10820](https://github.com/netbox-community/netbox/issues/10820) - Switch timezone library from pytz to zoneinfo
* [#10821](https://github.com/netbox-community/netbox/issues/10821) - Enable data localization
@@ -104,41 +266,39 @@ This release introduces a new programmatic API that enables plugins and custom s
* dcim.Device
* Added a `description` field
* dcim.DeviceType
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `weight`, and `weight_unit` fields
* dcim.Module
* Added a `description` field
* dcim.Interface
* Added the `vdcs` field
* dcim.Module
* Added a required `status` field
* dcim.ModuleType
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `weight`, and `weight_unit` fields
* dcim.PowerFeed
* Added a `description` field
* dcim.PowerPanel
* Added `description` and `comments` fields
* dcim.Rack
* Added a `description` field
* Added optional `weight` and `weight_unit` fields
* Added `description`, `mounting_depth`, `weight`, `max_weight`, and `weight_unit` fields
* dcim.RackReservation
* Added a `comments` field
* dcim.VirtualChassis
* Added `description` and `comments` fields
* extras.CustomField
* Added the `search_weight` field
* Added a `search_weight` field
* extras.CustomLink
* Renamed `content_type` field to `content_types`
* extras.ExportTemplate
* Renamed `content_type` field to `content_types`
* extras.JobResult
* Added `scheduled` and `started` datetime fields
* Added `interval`, `scheduled`, and `started` fields
* ipam.Aggregate
* Added a `comments` field
* ipam.ASN
* Added a `comments` field
* ipam.FHRPGroup
* Added a `comments` field
* Added optional `name` field
* Added `name` and `comments` fields
* ipam.IPAddress
* Added a `comments` field
* ipam.IPRange

View File

@@ -77,6 +77,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
model = CircuitTermination
fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'description',
]

View File

@@ -1,12 +1,16 @@
from django import forms
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.models import Site
from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField
from utilities.forms import BootstrapMixin, CSVChoiceField, CSVModelChoiceField, SlugField
__all__ = (
'CircuitImportForm',
'CircuitTerminationImportForm',
'CircuitTypeImportForm',
'ProviderImportForm',
'ProviderNetworkImportForm',
@@ -76,3 +80,23 @@ class CircuitImportForm(NetBoxModelImportForm):
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate',
'description', 'comments', 'tags'
]
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
required=False
)
provider_network = CSVModelChoiceField(
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False
)
class Meta:
model = CircuitTermination
fields = [
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'pp_info', 'description',
]

View File

@@ -145,16 +145,28 @@ class CircuitTerminationForm(NetBoxModelForm):
},
required=False
)
provider_network_provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
label='Provider',
initial_params={
'networks': 'provider_network'
}
)
provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(),
query_params={
'provider_id': '$provider_network_provider',
},
required=False
)
class Meta:
model = CircuitTermination
fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected',
'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'tags',
]
help_texts = {
'port_speed': _("Physical circuit speed"),

View File

@@ -104,6 +104,10 @@ class Circuit(PrimaryModel):
clone_fields = (
'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description',
)
prerequisite_models = (
'circuits.CircuitType',
'circuits.Provider',
)
class Meta:
ordering = ['provider', 'cid']
@@ -117,10 +121,6 @@ class Circuit(PrimaryModel):
def __str__(self):
return self.cid
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('circuits.Provider'), CircuitType]
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])
@@ -196,12 +196,10 @@ class CircuitTermination(
)
def __str__(self):
return f'Termination {self.term_side}: {self.site or self.provider_network}'
return f'{self.circuit}: Termination {self.term_side}'
def get_absolute_url(self):
if self.site:
return self.site.get_absolute_url()
return self.provider_network.get_absolute_url()
return self.circuit.get_absolute_url()
def clean(self):
super().clean()

View File

@@ -108,6 +108,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit
def setUp(self):
super().setUp()
self.add_permissions(
'circuits.add_circuittermination',
)
@classmethod
def setUpTestData(cls):

View File

@@ -233,6 +233,16 @@ class CircuitBulkImportView(generic.BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitImportForm
table = tables.CircuitTable
additional_permissions = [
'circuits.add_circuittermination',
]
related_object_forms = {
'terminations': forms.CircuitTerminationImportForm,
}
def prep_related_object_data(self, parent, data):
data.update({'circuit': parent})
return data
class CircuitBulkEditView(generic.BulkEditView):

View File

@@ -210,9 +210,9 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack
fields = [
'id', 'url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'weight_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated', 'device_count', 'powerfeed_count',
'asset_tag', 'type', 'width', 'u_height', 'weight', 'max_weight', 'weight_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
@@ -672,6 +672,22 @@ class DeviceSerializer(NetBoxModelSerializer):
return data
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField()
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.JSONField)
def get_config_context(self, obj):
return obj.get_config_context()
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device = NestedDeviceSerializer()
@@ -680,11 +696,15 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
# Related object counts
interface_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualDeviceContext
fields = [
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'interface_count',
]
@@ -693,31 +713,16 @@ class ModuleSerializer(NetBoxModelSerializer):
device = NestedDeviceSerializer()
module_bay = NestedModuleBaySerializer()
module_type = NestedModuleTypeSerializer()
status = ChoiceField(choices=ModuleStatusChoices, required=False)
class Meta:
model = Module
fields = [
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField()
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments',
'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@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.JSONField()
@@ -931,7 +936,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta:
model = RearPort
fields = ['id', 'url', 'display', 'name', 'label']
fields = ['id', 'url', 'display', 'name', 'label', 'description']
class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@@ -1055,7 +1060,7 @@ class TracedCableSerializer(serializers.ModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit',
'id', 'url', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'description',
]

View File

@@ -541,6 +541,8 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
queryset = VirtualDeviceContext.objects.prefetch_related(
'device__device_type', 'device', 'tenant', 'tags',
).annotate(
interface_count=count_related(Interface, 'vdcs'),
)
serializer_class = serializers.VirtualDeviceContextSerializer
filterset_class = filtersets.VirtualDeviceContextFilterSet

View File

@@ -55,14 +55,18 @@ class RackTypeChoices(ChoiceSet):
TYPE_4POST = '4-post-frame'
TYPE_CABINET = '4-post-cabinet'
TYPE_WALLFRAME = 'wall-frame'
TYPE_WALLFRAME_VERTICAL = 'wall-frame-vertical'
TYPE_WALLCABINET = 'wall-cabinet'
TYPE_WALLCABINET_VERTICAL = 'wall-cabinet-vertical'
CHOICES = (
(TYPE_2POST, '2-post frame'),
(TYPE_4POST, '4-post frame'),
(TYPE_CABINET, '4-post cabinet'),
(TYPE_WALLFRAME, 'Wall-mounted frame'),
(TYPE_WALLFRAME_VERTICAL, 'Wall-mounted frame (vertical)'),
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
(TYPE_WALLCABINET_VERTICAL, 'Wall-mounted cabinet (vertical)'),
)
@@ -194,6 +198,30 @@ class DeviceAirflowChoices(ChoiceSet):
)
#
# Modules
#
class ModuleStatusChoices(ChoiceSet):
key = 'Module.status'
STATUS_OFFLINE = 'offline'
STATUS_ACTIVE = 'active'
STATUS_PLANNED = 'planned'
STATUS_STAGED = 'staged'
STATUS_FAILED = 'failed'
STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGED, 'Staged', 'blue'),
(STATUS_FAILED, 'Failed', 'red'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
]
#
# ConsolePorts
#
@@ -757,7 +785,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_LAG = 'lag'
# Ethernet
TYPE_100ME_FX = '100base-fx'
TYPE_100ME_LFX = '100base-lfx'
TYPE_100ME_FIXED = '100base-tx'
TYPE_100ME_T1 = '100base-t1'
TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp'
@@ -782,6 +813,8 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
TYPE_800GE_OSFP = '800gbase-x-osfp'
# Ethernet Backplane
TYPE_1GE_KX = '1000base-kx'
@@ -890,7 +923,10 @@ class InterfaceTypeChoices(ChoiceSet):
(
'Ethernet (fixed)',
(
(TYPE_100ME_FX, '100BASE-FX (10/100ME FIBER)'),
(TYPE_100ME_LFX, '100BASE-LFX (10/100ME FIBER)'),
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
@@ -920,6 +956,8 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
(TYPE_400GE_OSFP, 'OSFP (400GE)'),
(TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
(TYPE_800GE_OSFP, 'OSFP (800GE)'),
)
),
(

View File

@@ -55,6 +55,8 @@ class MACAddressField(models.Field):
def to_python(self, value):
if value is None:
return value
if type(value) is str:
value = value.replace(' ', '')
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError:

View File

@@ -3,7 +3,7 @@ from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import ASN, VRF
from ipam.models import ASN, L2VPN, IPAddress, VRF
from netbox.filtersets import (
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
)
@@ -322,7 +322,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit'
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
]
def search(self, queryset, name, value):
@@ -958,6 +958,16 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_device_bays',
label=_('Has device bays'),
)
primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip4',
queryset=IPAddress.objects.all(),
label=_('Primary IPv4 (ID)'),
)
primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
field_name='primary_ip6',
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
class Meta:
model = Device
@@ -1082,18 +1092,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
queryset=Device.objects.all(),
label=_('Device (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=ModuleStatusChoices,
null_value=None
)
serial = MultiValueCharFilter(
lookup_expr='iexact'
)
class Meta:
model = Module
fields = ['id', 'asset_tag']
fields = ['id', 'status', 'asset_tag']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value.strip()) |
Q(serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)
@@ -1399,6 +1414,17 @@ class InterfaceFilterSet(
to_field_name='name',
label='Virtual Device Context',
)
l2vpn_id = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn',
queryset=L2VPN.objects.all(),
label=_('L2VPN (ID)'),
)
l2vpn = django_filters.ModelMultipleChoiceFilter(
field_name='l2vpn_terminations__l2vpn__identifier',
queryset=L2VPN.objects.all(),
to_field_name='identifier',
label=_('L2VPN'),
)
class Meta:
model = Interface

View File

@@ -294,6 +294,10 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
min_value=0,
required=False
)
max_weight = forms.IntegerField(
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
required=False,
@@ -316,11 +320,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
('Hardware', (
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
)),
('Weight', ('weight', 'weight_unit')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
)
nullable_fields = (
'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'weight_unit', 'description', 'comments',
'max_weight', 'weight_unit', 'description', 'comments',
)
@@ -574,6 +578,12 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
'manufacturer_id': '$manufacturer'
}
)
status = forms.ChoiceField(
choices=add_blank_choice(ModuleStatusChoices),
required=False,
initial='',
widget=StaticSelect()
)
serial = forms.CharField(
max_length=50,
required=False,
@@ -590,7 +600,7 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
model = Module
fieldsets = (
(None, ('manufacturer', 'module_type', 'serial', 'description')),
(None, ('manufacturer', 'module_type', 'status', 'serial', 'description')),
)
nullable_fields = ('serial', 'description', 'comments')
@@ -1321,7 +1331,7 @@ class FrontPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class RearPortBulkEditForm(
@@ -1332,7 +1342,7 @@ class RearPortBulkEditForm(
fieldsets = (
(None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')),
)
nullable_fields = ('module', 'label', 'description')
nullable_fields = ('module', 'label', 'description', 'color')
class ModuleBayBulkEditForm(

View File

@@ -14,10 +14,10 @@ from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm
__all__ = (
'CableImportForm',
'ChildDeviceImportForm',
'ConsolePortImportForm',
'ConsoleServerPortImportForm',
'DeviceBayImportForm',
@@ -195,13 +195,18 @@ class RackImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
weight_unit = CSVChoiceField(
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for rack weights')
)
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -407,6 +412,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False,
help_text=_('Mounted rack face')
)
parent = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text=_('Parent device (for child devices)')
)
device_bay = CSVModelChoiceField(
queryset=DeviceBay.objects.all(),
to_field_name='name',
required=False,
help_text=_('Device bay in which this device is installed (for child devices)')
)
airflow = CSVChoiceField(
choices=DeviceAirflowChoices,
required=False,
@@ -416,8 +433,8 @@ class DeviceImportForm(BaseDeviceImportForm):
class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'airflow', 'virtual_chassis', 'vc_position', 'vc_priority',
'cluster', 'description', 'comments', 'tags',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -428,6 +445,7 @@ class DeviceImportForm(BaseDeviceImportForm):
# Limit location queryset by assigned site
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
# Limit rack queryset by assigned site and group
params = {
@@ -436,25 +454,58 @@ class DeviceImportForm(BaseDeviceImportForm):
}
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
# Limit device bay queryset by parent device
if parent := data.get('parent'):
params = {f"device__{self.fields['parent'].to_field_name}": parent}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
class ModuleImportForm(NetBoxModelImportForm):
def clean(self):
super().clean()
# Inherit site and rack from parent device
if parent := self.cleaned_data.get('parent'):
self.instance.site = parent.site
self.instance.rack = parent.rack
# Set parent_bay reverse relationship
if device_bay := self.cleaned_data.get('device_bay'):
self.instance.parent_bay = device_bay
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name'
to_field_name='name',
help_text=_('The device in which this module is installed')
)
module_bay = CSVModelChoiceField(
queryset=ModuleBay.objects.all(),
to_field_name='name'
to_field_name='name',
help_text=_('The module bay in which this module is installed')
)
module_type = CSVModelChoiceField(
queryset=ModuleType.objects.all(),
to_field_name='model'
to_field_name='model',
help_text=_('The type of module')
)
status = CSVChoiceField(
choices=ModuleStatusChoices,
help_text=_('Operational status')
)
replicate_components = forms.BooleanField(
required=False,
help_text=_('Automatically populate components associated with this module type (enabled by default)')
)
adopt_components = forms.BooleanField(
required=False,
help_text=_('Adopt already existing components')
)
class Meta:
model = Module
fields = (
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'description', 'comments', 'tags',
'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'status', 'description', 'comments',
'replicate_components', 'adopt_components', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -465,47 +516,12 @@ class ModuleImportForm(NetBoxModelImportForm):
params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
class ChildDeviceImportForm(BaseDeviceImportForm):
parent = CSVModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Parent device')
)
device_bay = CSVModelChoiceField(
queryset=DeviceBay.objects.all(),
to_field_name='name',
help_text=_('Device bay in which this device is installed')
)
class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', 'tags'
]
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit device bay queryset by parent device
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
def clean(self):
super().clean()
# Set parent_bay reverse relationship
device_bay = self.cleaned_data.get('device_bay')
if device_bay:
self.instance.parent_bay = device_bay
# Inherit site and rack from parent device
parent = self.cleaned_data.get('parent')
if parent:
self.instance.site = parent.site
self.instance.rack = parent.rack
def clean_replicate_components(self):
# Make sure replicate_components is True when it's not included in the uploaded data
if 'replicate_components' not in self.data:
return True
else:
return self.cleaned_data['replicate_components']
#
@@ -856,12 +872,22 @@ class InventoryItemImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Parent inventory item')
)
component_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
required=False,
help_text=_('Component Type')
)
component_name = forms.CharField(
required=False,
help_text=_('Component Name')
)
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags'
'description', 'tags', 'component_type', 'component_name',
)
def __init__(self, *args, **kwargs):
@@ -879,6 +905,24 @@ class InventoryItemImportForm(NetBoxModelImportForm):
else:
self.fields['parent'].queryset = InventoryItem.objects.none()
def clean_component_name(self):
content_type = self.cleaned_data.get('component_type')
component_name = self.cleaned_data.get('component_name')
device = self.cleaned_data.get("device")
if not device and hasattr(self, 'instance'):
device = self.instance.device
if not all([device, content_type, component_name]):
return None
model = content_type.model_class()
try:
component = model.objects.get(device=device, name=component_name)
self.instance.component = component
except ObjectDoesNotExist:
raise forms.ValidationError(f"Component not found: {device} - {component_name}")
#
# Device component roles

View File

@@ -6,6 +6,7 @@ from dcim.constants import *
__all__ = (
'InterfaceCommonForm',
'ModuleCommonForm'
)
@@ -48,3 +49,62 @@ class InterfaceCommonForm(forms.Form):
'tagged_vlans': f"The tagged VLANs ({', '.join(invalid_vlans)}) must belong to the same site as "
f"the interface's parent device/VM, or they must be global"
})
class ModuleCommonForm(forms.Form):
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get('replicate_components')
adopt_components = self.cleaned_data.get('adopt_components')
device = self.cleaned_data.get('device')
module_type = self.cleaned_data.get('module_type')
module_bay = self.cleaned_data.get('module_bay')
if adopt_components:
self.instance._adopt_components = True
# Bail out if we are not installing a new module or if we are not replicating components (or if
# validation has already failed)
if self.errors or self.instance.pk or not replicate_components:
self.instance._disable_replication = True
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)

View File

@@ -6,7 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
from ipam.models import ASN, VRF
from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
from utilities.forms import (
@@ -229,7 +229,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'weight_unit')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -284,7 +284,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
)
tag = TagFilterField(model)
weight = forms.DecimalField(
required=False
required=False,
min_value=1
)
max_weight = forms.IntegerField(
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
choices=add_blank_choice(WeightUnitChoices),
@@ -763,7 +768,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -780,6 +785,10 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
label=_('Type'),
fetch_trigger='open'
)
status = MultipleChoiceField(
choices=ModuleStatusChoices,
required=False
)
serial = forms.CharField(
required=False
)
@@ -1103,7 +1112,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
@@ -1161,7 +1170,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label='PoE mode'
)
poe_type = MultipleChoiceField(
choices=InterfacePoEModeChoices,
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
)
@@ -1194,6 +1203,11 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
required=False,
label='VRF'
)
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
required=False,
label=_('L2VPN')
)
tag = TagFilterField(model)

View File

@@ -17,7 +17,7 @@ from utilities.forms import (
)
from virtualization.models import Cluster, ClusterGroup
from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm
from .common import InterfaceCommonForm, ModuleCommonForm
__all__ = (
'CableForm',
@@ -279,7 +279,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'description', 'comments', 'tags',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
]
help_texts = {
'site': _("The site at which the rack exists"),
@@ -662,7 +662,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['position'].widget.choices = [(position, f'U{position}')]
class ModuleForm(NetBoxModelForm):
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
initial_params={
@@ -703,7 +703,7 @@ class ModuleForm(NetBoxModelForm):
fieldsets = (
('Module', (
'device', 'module_bay', 'manufacturer', 'module_type', 'description', 'tags',
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
)),
('Hardware', (
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
@@ -713,7 +713,7 @@ class ModuleForm(NetBoxModelForm):
class Meta:
model = Module
fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags',
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
'replicate_components', 'adopt_components', 'description', 'comments',
]
@@ -727,68 +727,6 @@ class ModuleForm(NetBoxModelForm):
self.fields['adopt_components'].initial = False
self.fields['adopt_components'].disabled = True
def save(self, *args, **kwargs):
# If replicate_components is False, disable automatic component replication on the instance
if self.instance.pk or not self.cleaned_data['replicate_components']:
self.instance._disable_replication = True
if self.cleaned_data['adopt_components']:
self.instance._adopt_components = True
return super().save(*args, **kwargs)
def clean(self):
super().clean()
replicate_components = self.cleaned_data.get("replicate_components")
adopt_components = self.cleaned_data.get("adopt_components")
device = self.cleaned_data['device']
module_type = self.cleaned_data['module_type']
module_bay = self.cleaned_data['module_bay']
# Bail out if we are not installing a new module or if we are not replicating components
if self.instance.pk or not replicate_components:
return
for templates, component_attribute in [
("consoleporttemplates", "consoleports"),
("consoleserverporttemplates", "consoleserverports"),
("interfacetemplates", "interfaces"),
("powerporttemplates", "powerports"),
("poweroutlettemplates", "poweroutlets"),
("rearporttemplates", "rearports"),
("frontporttemplates", "frontports")
]:
# Prefetch installed components
installed_components = {
component.name: component for component in getattr(device, component_attribute).all()
}
# Get the templates for the module type.
for template in getattr(module_type, templates).all():
# Installing modules with placeholders require that the bay has a position value
if MODULE_TOKEN in template.name and not module_bay.position:
raise forms.ValidationError(
"Cannot install module with placeholder values in a module bay with no position defined"
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
existing_item = installed_components.get(resolved_name)
# It is not possible to adopt components already belonging to a module
if adopt_components and existing_item and existing_item.module:
raise forms.ValidationError(
f"Cannot adopt {template.component_model.__name__} '{resolved_name}' as it already belongs "
f"to a module"
)
# If we are not adopting components we error if the component exists
if not adopt_components and resolved_name in installed_components:
raise forms.ValidationError(
f"{template.component_model.__name__} - {resolved_name} already exists"
)
class CableForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
@@ -1462,7 +1400,6 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
required=False,
label=_('VRF')
)
wwn = forms.CharField(
empty_value=None,
required=False,
@@ -1470,9 +1407,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
)
fieldsets = (
('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', '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')),
('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('Related Interfaces', ('parent', 'bridge', 'lag')),
('PoE', ('poe_mode', 'poe_type')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
@@ -1612,15 +1549,63 @@ class InventoryItemForm(DeviceComponentForm):
queryset=Manufacturer.objects.all(),
required=False
)
component_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
# Assigned component selectors
consoleport = DynamicModelChoiceField(
queryset=ConsolePort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console port')
)
component_id = forms.IntegerField(
consoleserverport = DynamicModelChoiceField(
queryset=ConsoleServerPort.objects.all(),
required=False,
widget=forms.HiddenInput
query_params={
'device_id': '$device'
},
label=_('Console server port')
)
frontport = DynamicModelChoiceField(
queryset=FrontPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Front port')
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Interface')
)
poweroutlet = DynamicModelChoiceField(
queryset=PowerOutlet.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power outlet')
)
powerport = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Power port')
)
rearport = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
required=False,
query_params={
'device_id': '$device'
},
label=_('Rear port')
)
fieldsets = (
@@ -1632,11 +1617,57 @@ class InventoryItemForm(DeviceComponentForm):
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
'description', 'component_type', 'component_id', 'tags',
'description', 'tags',
]
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
component_type = initial.get('component_type')
component_id = initial.get('component_id')
# Used for picking the default active tab for component selection
self.no_component = True
if instance:
# When editing set the initial value for component selectin
for component_model in ContentType.objects.filter(MODULAR_COMPONENT_MODELS):
if type(instance.component) is component_model.model_class():
initial[component_model.model] = instance.component
self.no_component = False
break
elif component_type and component_id:
# When adding the InventoryItem from a component page
if content_type := ContentType.objects.filter(MODULAR_COMPONENT_MODELS).filter(pk=component_type).first():
if component := content_type.model_class().objects.filter(pk=component_id).first():
initial[content_type.model] = component
self.no_component = False
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
# Specifically allow editing the device of IntentoryItems
if self.instance.pk:
self.fields['device'].disabled = False
def clean(self):
super().clean()
# Handle object assignment
selected_objects = [
field for field in (
'consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
elif selected_objects:
self.instance.component = self.cleaned_data[selected_objects[0]]
else:
self.instance.component = None
#
# Device component roles
#
@@ -1705,6 +1736,24 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
'rack_id': '$rack',
}
)
primary_ip4 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv4',
required=False,
query_params={
'device_id': '$device',
'family': '4',
}
)
primary_ip6 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv6',
required=False,
query_params={
'device_id': '$device',
'family': '6',
}
)
fieldsets = (
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),

View File

@@ -1,5 +1,3 @@
# Generated by Django 4.0.7 on 2022-09-23 01:01
from django.db import migrations, models
@@ -10,11 +8,8 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AddField(
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Device types
migrations.AddField(
model_name='devicetype',
name='weight',
@@ -26,10 +21,12 @@ class Migration(migrations.Migration):
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='moduletype',
model_name='devicetype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Module types
migrations.AddField(
model_name='moduletype',
name='weight',
@@ -41,18 +38,35 @@ class Migration(migrations.Migration):
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
model_name='moduletype',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
# Racks
migrations.AddField(
model_name='rack',
name='weight',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True),
),
migrations.AddField(
model_name='rack',
name='max_weight',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='weight_unit',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='_abs_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='rack',
name='_abs_max_weight',
field=models.PositiveBigIntegerField(blank=True, null=True),
),
]

View File

@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0163_rack_devicetype_moduletype_weights'),
('dcim', '0163_weight_fields'),
]
operations = [

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.2 on 2022-12-09 15:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0166_virtualdevicecontext'),
]
operations = [
migrations.AddField(
model_name='module',
name='status',
field=models.CharField(default='active', max_length=50),
),
]

View File

@@ -112,6 +112,10 @@ class Cable(PrimaryModel):
def a_terminations(self):
if hasattr(self, '_a_terminations'):
return self._a_terminations
if not self.pk:
return []
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A
@@ -119,13 +123,18 @@ class Cable(PrimaryModel):
@a_terminations.setter
def a_terminations(self, value):
self._terminations_modified = True
if not self.pk or self.a_terminations != list(value):
self._terminations_modified = True
self._a_terminations = value
@property
def b_terminations(self):
if hasattr(self, '_b_terminations'):
return self._b_terminations
if not self.pk:
return []
# Query self.terminations.all() to leverage cached results
return [
ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B
@@ -133,7 +142,8 @@ class Cable(PrimaryModel):
@b_terminations.setter
def b_terminations(self, value):
self._terminations_modified = True
if not self.pk or self.b_terminations != list(value):
self._terminations_modified = True
self._b_terminations = value
def clean(self):
@@ -279,6 +289,17 @@ class CableTermination(models.Model):
def clean(self):
super().clean()
# Check for existing termination
existing_termination = CableTermination.objects.exclude(cable=self.cable).filter(
termination_type=self.termination_type,
termination_id=self.termination_id
).first()
if existing_termination is not None:
raise ValidationError(
f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
f"{self.termination_id}: cable {existing_termination.cable.pk}"
)
# Validate interface type (if applicable)
if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
@@ -516,7 +537,7 @@ class CablePath(models.Model):
# Step 5: Record the far-end termination object(s)
path.append([
object_to_path_node(t) for t in remote_terminations
object_to_path_node(t) for t in remote_terminations if t is not None
])
# Step 6: Determine the "next hop" terminations, if applicable
@@ -556,11 +577,12 @@ class CablePath(models.Model):
elif isinstance(remote_terminations[0], CircuitTermination):
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
term_side = remote_terminations[0].term_side
assert all(ct.term_side == term_side for ct in remote_terminations[1:])
if len(remote_terminations) > 1:
is_split = True
break
circuit_termination = CircuitTermination.objects.filter(
circuit=remote_terminations[0].circuit,
term_side='Z' if term_side == 'A' else 'A'
term_side='Z' if remote_terminations[0].term_side == 'A' else 'A'
).first()
if circuit_termination is None:
break
@@ -570,6 +592,7 @@ class CablePath(models.Model):
[object_to_path_node(circuit_termination)],
[object_to_path_node(circuit_termination.provider_network)],
])
is_complete = True
break
elif circuit_termination.site and not circuit_termination.cable:
# Circuit terminates to a Site
@@ -673,6 +696,7 @@ class CablePath(models.Model):
"""
Return all available next segments in a split cable path.
"""
from circuits.models import CircuitTermination
nodes = self.path_objects[-1]
# RearPort splitting to multiple FrontPorts with no stack position
@@ -682,3 +706,8 @@ class CablePath(models.Model):
# 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])
# Cable terminating to multiple CircuitTerminations
elif type(nodes[0]) is CircuitTermination:
return [
ct.get_peer_termination() for ct in nodes
]

View File

@@ -197,7 +197,7 @@ class PathEndpoint(models.Model):
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
CablePath model. `_path` should not be accessed directly; rather, use the `path` property.
`connected_endpoint()` is a convenience method for returning the destination of the associated CablePath, if any.
`connected_endpoints()` is a convenience method for returning the destination of the associated CablePath, if any.
"""
_path = models.ForeignKey(
to='dcim.CablePath',
@@ -1129,3 +1129,25 @@ class InventoryItem(MPTTModel, ComponentModel):
raise ValidationError({
"parent": "Cannot assign self as parent."
})
# Validation for moving InventoryItems
if self.pk:
# Cannot move an InventoryItem to another device if it has a parent
if self.parent and self.parent.device != self.device:
raise ValidationError({
"parent": "Parent inventory item does not belong to the same device."
})
# Prevent moving InventoryItems with children
first_child = self.get_children().first()
if first_child and first_child.device != self.device:
raise ValidationError("Cannot move an inventory item with dependent children")
# When moving an InventoryItem to another device, remove any associated component
if self.component and self.component.device != self.device:
self.component = None
else:
if self.component and self.component.device != self.device:
raise ValidationError({
"device": "Cannot assign inventory item to component on another device"
})

View File

@@ -3,7 +3,6 @@ import yaml
from functools import cached_property
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -124,6 +123,9 @@ class DeviceType(PrimaryModel, WeightMixin):
clone_fields = (
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ['manufacturer', 'model']
@@ -151,10 +153,6 @@ class DeviceType(PrimaryModel, WeightMixin):
self._original_front_image = self.front_image
self._original_rear_image = self.rear_image
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:devicetype', args=[self.pk])
@@ -325,6 +323,9 @@ class ModuleType(PrimaryModel, WeightMixin):
)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ('manufacturer', 'model')
@@ -338,10 +339,6 @@ class ModuleType(PrimaryModel, WeightMixin):
def __str__(self):
return self.model
@classmethod
def get_prerequisite_models(cls):
return [Manufacturer, ]
def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk])
@@ -599,6 +596,11 @@ class Device(PrimaryModel, ConfigContextModel):
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'cluster', 'virtual_chassis',
)
prerequisite_models = (
'dcim.Site',
'dcim.DeviceRole',
'dcim.DeviceType',
)
class Meta:
ordering = ('_name', 'pk') # Name may be null
@@ -638,10 +640,6 @@ class Device(PrimaryModel, ConfigContextModel):
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})'
return super().__str__()
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), DeviceRole, DeviceType, ]
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@@ -927,6 +925,11 @@ class Module(PrimaryModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='instances'
)
status = models.CharField(
max_length=50,
choices=ModuleStatusChoices,
default=ModuleStatusChoices.STATUS_ACTIVE
)
serial = models.CharField(
max_length=50,
blank=True,
@@ -941,7 +944,7 @@ class Module(PrimaryModel, ConfigContextModel):
help_text=_('A unique tag used to identify this device')
)
clone_fields = ('device', 'module_type')
clone_fields = ('device', 'module_type', 'status')
class Meta:
ordering = ('module_bay',)
@@ -952,10 +955,13 @@ class Module(PrimaryModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:module', args=[self.pk])
def get_status_color(self):
return ModuleStatusChoices.colors.get(self.status)
def clean(self):
super().clean()
if self.module_bay.device != self.device:
if hasattr(self, "module_bay") and (self.module_bay.device != self.device):
raise ValidationError(
f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
)
@@ -1176,3 +1182,20 @@ class VirtualDeviceContext(PrimaryModel):
return self.primary_ip4
else:
return None
def clean(self):
super().clean()
# Validate primary IPv4/v6 assignment
for primary_ip, family in ((self.primary_ip4, 4), (self.primary_ip6, 6)):
if not primary_ip:
continue
if primary_ip.family != family:
raise ValidationError({
f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
})
device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces:
raise ValidationError({
f'primary_ip{family}': _('Primary IP address must belong to an interface on the assigned device.')
})

View File

@@ -39,7 +39,5 @@ class WeightMixin(models.Model):
super().clean()
# Validate weight and weight_unit
if self.weight is not None and not self.weight_unit:
if self.weight and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a weight")
elif self.weight is None:
self.weight_unit = ''

View File

@@ -1,4 +1,3 @@
from django.apps import apps
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -48,6 +47,10 @@ class PowerPanel(PrimaryModel):
to='extras.ImageAttachment'
)
prerequisite_models = (
'dcim.Site',
)
class Meta:
ordering = ['site', 'name']
constraints = (
@@ -60,10 +63,6 @@ class PowerPanel(PrimaryModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:powerpanel', args=[self.pk])
@@ -137,6 +136,9 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization',
)
prerequisite_models = (
'dcim.PowerPanel',
)
class Meta:
ordering = ['power_panel', 'name']
@@ -150,10 +152,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
def __str__(self):
return self.name
@classmethod
def get_prerequisite_models(cls):
return [PowerPanel, ]
def get_absolute_url(self):
return reverse('dcim:powerfeed', args=[self.pk])

View File

@@ -1,7 +1,6 @@
import decimal
from functools import cached_property
from django.apps import apps
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField
@@ -18,7 +17,7 @@ from dcim.svg import RackElevationSVG
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.utils import array_to_string, drange
from utilities.utils import array_to_string, drange, to_grams
from .device_components import PowerPort
from .devices import Device, Module
from .mixins import WeightMixin
@@ -150,6 +149,16 @@ class Rack(PrimaryModel, WeightMixin):
choices=RackDimensionUnitChoices,
blank=True,
)
max_weight = models.PositiveIntegerField(
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
mounting_depth = models.PositiveSmallIntegerField(
blank=True,
null=True,
@@ -175,7 +184,10 @@ class Rack(PrimaryModel, WeightMixin):
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'weight_unit',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Site',
)
class Meta:
@@ -197,10 +209,6 @@ class Rack(PrimaryModel, WeightMixin):
return f'{self.name} ({self.facility_id})'
return self.name
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), ]
def get_absolute_url(self):
return reverse('dcim:rack', args=[self.pk])
@@ -217,6 +225,10 @@ class Rack(PrimaryModel, WeightMixin):
elif self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a maximum weight")
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(
@@ -239,6 +251,16 @@ class Rack(PrimaryModel, WeightMixin):
'location': f"Location must be from the same site, {self.site}."
})
def save(self, *args, **kwargs):
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
else:
self._abs_max_weight = None
super().save(*args, **kwargs)
@property
def units(self):
"""
@@ -488,16 +510,17 @@ class RackReservation(PrimaryModel):
max_length=200
)
clone_fields = ('rack', 'user', 'tenant')
prerequisite_models = (
'dcim.Rack',
)
class Meta:
ordering = ['created', 'pk']
def __str__(self):
return "Reservation for rack {}".format(self.rack)
@classmethod
def get_prerequisite_models(cls):
return [apps.get_model('dcim.Site'), Rack, ]
def get_absolute_url(self):
return reverse('dcim:rackreservation', args=[self.pk])

View File

@@ -286,6 +286,9 @@ class Location(NestedGroupModel):
)
clone_fields = ('site', 'parent', 'status', 'tenant', 'description')
prerequisite_models = (
'dcim.Site',
)
class Meta:
ordering = ['site', 'name']
@@ -312,10 +315,6 @@ class Location(NestedGroupModel):
),
)
@classmethod
def get_prerequisite_models(cls):
return [Site, ]
def get_absolute_url(self):
return reverse('dcim:location', args=[self.pk])

View File

@@ -4,7 +4,9 @@ from django.db.models.signals import post_save, post_delete, pre_delete
from django.dispatch import receiver
from .choices import CableEndChoices, LinkStatusChoices
from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
from .models import (
Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis,
)
from .models.cables import trace_paths
from .utils import create_cablepath, rebuild_paths
@@ -122,4 +124,18 @@ def nullify_connected_endpoints(instance, **kwargs):
model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable):
# Remove the deleted CableTermination if it's one of the path's originating nodes
if instance.termination in cablepath.origins:
cablepath.origins.remove(instance.termination)
cablepath.retrace()
@receiver(post_save, sender=FrontPort)
def extend_rearport_cable_paths(instance, created, raw, **kwargs):
"""
When a new FrontPort is created, add it to any CablePaths which end at its corresponding RearPort.
"""
if created and not raw:
rearport = instance.rear_port
for cablepath in CablePath.objects.filter(_nodes__contains=rearport):
cablepath.retrace()

View File

@@ -139,7 +139,8 @@ class PlatformTable(NetBoxTable):
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
name = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
region = tables.Column(
@@ -220,7 +221,8 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
name = tables.TemplateColumn(
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
site = tables.Column(
@@ -504,6 +506,9 @@ class BaseInterfaceTable(NetBoxTable):
verbose_name='Tagged VLANs'
)
def value_ip_addresses(self, value):
return ",".join([str(obj.address) for obj in value.all()])
class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable):
device = tables.Column(
@@ -521,6 +526,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
orderable=False,
verbose_name='Wireless LANs'
)
vdcs = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='VDCs'
)
vrf = tables.Column(
linkify=True
)
@@ -534,7 +543,7 @@ 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', 'l2vpn',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -568,10 +577,9 @@ 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', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups',
'untagged_vlan', 'tagged_vlans', 'actions',
)
order_by = ('name',)
default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection',
@@ -893,7 +901,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
)
device = tables.TemplateColumn(
order_by=('_name',),
template_code=DEVICE_LINK
template_code=DEVICE_LINK,
linkify=True
)
status = columns.ChoiceFieldColumn()
primary_ip = tables.Column(
@@ -909,6 +918,11 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
linkify=True,
verbose_name='IPv6 Address'
)
interface_count = columns.LinkedCountColumn(
viewname='dcim:interface_list',
url_params={'vdc_id': 'pk'},
verbose_name='Interfaces'
)
comments = columns.MarkdownColumn()
@@ -919,8 +933,8 @@ class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.VirtualDeviceContext
fields = (
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group', 'primary_ip', 'primary_ip4',
'primary_ip6', 'comments', 'tags', 'interface_count', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',

View File

@@ -3,7 +3,7 @@ import django_tables2 as tables
from dcim import models
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, DEVICE_WEIGHT
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS, WEIGHT
__all__ = (
'ConsolePortTemplateTable',
@@ -34,10 +34,19 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
url_params={'manufacturer_id': 'pk'},
verbose_name='Device Types'
)
inventoryitem_count = tables.Column(
moduletype_count = columns.LinkedCountColumn(
viewname='dcim:moduletype_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Module Types'
)
inventoryitem_count = columns.LinkedCountColumn(
viewname='dcim:inventoryitem_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Inventory Items'
)
platform_count = tables.Column(
platform_count = columns.LinkedCountColumn(
viewname='dcim:platform_list',
url_params={'manufacturer_id': 'pk'},
verbose_name='Platforms'
)
slug = tables.Column()
@@ -48,11 +57,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'contacts', 'actions', 'created', 'last_updated',
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
'description', 'slug',
)
@@ -84,7 +94,7 @@ class DeviceTypeTable(NetBoxTable):
template_code='{{ value|floatformat }}'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from dcim.models import Module, ModuleType
from netbox.tables import NetBoxTable, columns
from .template_code import DEVICE_WEIGHT
from .template_code import WEIGHT
__all__ = (
'ModuleTable',
@@ -28,7 +28,7 @@ class ModuleTypeTable(NetBoxTable):
url_name='dcim:moduletype_list'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
@@ -56,6 +56,7 @@ class ModuleTable(NetBoxTable):
module_type = tables.Column(
linkify=True
)
status = columns.ChoiceFieldColumn()
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:module_list'
@@ -64,9 +65,9 @@ class ModuleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Module
fields = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'description',
'comments', 'tags',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
'description', 'comments', 'tags',
)
default_columns = (
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag',
)

View File

@@ -78,7 +78,7 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import DEVICE_WEIGHT
from .template_code import WEIGHT
__all__ = (
'RackTable',
@@ -81,17 +81,21 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
verbose_name='Outer Depth'
)
weight = columns.TemplateColumn(
template_code=DEVICE_WEIGHT,
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
max_weight = columns.TemplateColumn(
template_code=WEIGHT,
order_by=('_abs_max_weight', 'weight_unit')
)
class Meta(NetBoxTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'u_height', 'width', 'outer_width', 'outer_depth', 'mounting_depth', 'weight',
'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags',
'created', 'last_updated',
'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'description',
'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',

View File

@@ -99,9 +99,9 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Site
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
'contacts', 'tags', 'created', 'last_updated', 'actions',
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns',
'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude',
'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')

View File

@@ -15,15 +15,13 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %}
"""
DEVICE_WEIGHT = """
WEIGHT = """
{% load helpers %}
{% if record.weight %}{{ record.weight|simplify_decimal }} {{ record.weight_unit }}{% endif %}
{% if value %}{{ value|simplify_decimal }} {{ record.weight_unit }}{% endif %}
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="badge bg-info">Unnamed device</span>' }}
</a>
{{ value|default:'<span class="badge bg-info">Unnamed device</span>' }}
"""
DEVICEBAY_STATUS = """
@@ -117,10 +115,28 @@ CONSOLEPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
@@ -149,10 +165,28 @@ CONSOLESERVERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:consoleserverport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
@@ -181,10 +215,28 @@ POWERPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:powerport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
@@ -212,10 +264,28 @@ POWEROUTLET_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:poweroutlet_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
@@ -260,10 +330,28 @@ INTERFACE_BUTTONS = """
{% endif %}
{% if record.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif record.wireless_link %}
{% if perms.wireless.delete_wirelesslink %}
@@ -305,10 +393,28 @@ FRONTPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:frontport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
@@ -342,10 +448,28 @@ REARPORT_BUTTONS = """
{% if record.cable %}
<a href="{% url 'dcim:rearport_trace' pk=record.pk %}" class="btn btn-primary btn-sm" title="Trace"><i class="mdi mdi-transit-connection-variant"></i></a>
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" title="Remove cable" class="btn btn-danger btn-sm">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i>
</a>
{% if perms.dcim.change_cable or perms.dcim.delete_cable %}
<span class="dropdown">
<button type="button" class="btn btn-warning btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if perms.dcim.change_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_edit' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">
<i class="mdi mdi-pencil-outline"></i>
Edit cable
</a>
</li>
{% endif %}
{% if perms.dcim.delete_cable %}
<li><a class="dropdown-item" href="{% url 'dcim:cable_delete' pk=record.cable.pk %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">
<i class="mdi mdi-trash-can-outline"></i>
Delete cable
</a>
</li>
{% endif %}
</ul>
</span>
{% endif %}
{% elif perms.dcim.add_cable %}
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>

View File

@@ -1271,6 +1271,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'ABC123',
'asset_tag': 'Foo1',
},
@@ -1278,6 +1279,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[4].pk,
'module_type': module_types[1].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'DEF456',
'asset_tag': 'Foo2',
},
@@ -1285,6 +1287,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
'device': device.pk,
'module_bay': module_bays[5].pk,
'module_type': module_types[2].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'GHI789',
'asset_tag': 'Foo3',
},
@@ -1954,37 +1957,37 @@ class CableTest(APIViewTestCases.APIViewTestCase):
class ConnectedDeviceTest(APITestCase):
def setUp(self):
super().setUp()
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
self.device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
devices = (
Device(device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site),
Device(device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site),
)
self.device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='eth0'),
Interface(device=devices[1], name='eth0'),
Interface(device=devices[0], name='eth1'), # Not connected
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected
Interface.objects.bulk_create(interfaces)
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
cable = Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[1]])
cable.save()
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface1.name}'
url_params = f'?peer_device=TestDevice1&peer_interface=eth0'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device2.name)
self.assertEqual(response.data['name'], 'TestDevice2')
url_params = f'?peer_device={self.device1.name}&peer_interface={self.interface3.name}'
url_params = f'?peer_device=TestDevice1&peer_interface=eth1'
response = self.client.get(url + url_params, **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)

View File

@@ -1323,6 +1323,7 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Delete cable 1
cable1.delete()
@@ -1803,3 +1804,44 @@ class CablePathTestCase(TestCase):
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
def test_303_remove_termination_from_existing_cable(self):
"""
[IF1] --C1-- [IF2]
[IF3]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
# Create cables 1
cable1 = Cable(
a_terminations=[interface1],
b_terminations=[interface2, interface3]
)
cable1.save()
self.assertPathExists(
(interface1, cable1, [interface2, interface3]),
is_complete=True,
is_active=True
)
self.assertPathExists(
([interface2, interface3], cable1, interface1),
is_complete=True,
is_active=True
)
# Remove the termination to interface 3
cable1 = Cable.objects.first()
cable1.b_terminations = [interface2]
cable1.save()
self.assertPathExists(
(interface1, cable1, interface2),
is_complete=True,
is_active=True
)
self.assertPathExists(
(interface2, cable1, interface1),
is_complete=True,
is_active=True
)

View File

@@ -409,9 +409,9 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
racks = (
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=10, max_weight=1000, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, weight=20, max_weight=2000, weight_unit=WeightUnitChoices.UNIT_POUND),
Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH, weight=30, max_weight=3000, weight_unit=WeightUnitChoices.UNIT_KILOGRAM),
)
Rack.objects.bulk_create(racks)
@@ -521,6 +521,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_max_weight(self):
params = {'max_weight': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1622,10 +1626,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
ipaddresses = (
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
IPAddress(address='192.0.2.3/24', assigned_object=None),
IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
IPAddress(address='2001:db8::3/64', assigned_object=None),
)
IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1])
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
Device.objects.filter(pk=devices[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
# VirtualChassis assignment for filtering
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
@@ -1757,6 +1765,20 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'has_primary_ip': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_primary_ip4(self):
addresses = IPAddress.objects.filter(address__family=4)
params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip4_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_primary_ip6(self):
addresses = IPAddress.objects.filter(address__family=6)
params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'primary_ip6_id': [addresses[2].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
def test_virtual_chassis_id(self):
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1876,15 +1898,15 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
ModuleBay.objects.bulk_create(module_bays)
modules = (
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='A', asset_tag='A'),
Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='B', asset_tag='B'),
Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='C', asset_tag='C'),
Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='D', asset_tag='D'),
Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], status=ModuleStatusChoices.STATUS_ACTIVE, serial='E', asset_tag='E'),
Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], status=ModuleStatusChoices.STATUS_ACTIVE, serial='F', asset_tag='F'),
Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], status=ModuleStatusChoices.STATUS_ACTIVE, serial='G', asset_tag='G'),
Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], status=ModuleStatusChoices.STATUS_PLANNED, serial='H', asset_tag='H'),
Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], status=ModuleStatusChoices.STATUS_FAILED, serial='I', asset_tag='I'),
)
Module.objects.bulk_create(modules)
@@ -1912,6 +1934,10 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
def test_status(self):
params = {'status': [ModuleStatusChoices.STATUS_PLANNED, ModuleStatusChoices.STATUS_FAILED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_serial(self):
params = {'serial': ['A', 'B']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -73,7 +73,8 @@ class LocationTestCase(TestCase):
class RackTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site 1', slug='site-1'),
@@ -240,30 +241,31 @@ class RackTestCase(TestCase):
class DeviceTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.device_type = DeviceType.objects.create(
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.device_role = DeviceRole.objects.create(
device_role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
# Create DeviceType components
ConsolePortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Console Port 1'
).save()
ConsoleServerPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
@@ -271,21 +273,21 @@ class DeviceTestCase(TestCase):
ppt.save()
PowerOutletTemplate(
device_type=self.device_type,
device_type=device_type,
name='Power Outlet 1',
power_port=ppt,
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
).save()
InterfaceTemplate(
device_type=self.device_type,
device_type=device_type,
name='Interface 1',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
mgmt_only=True
).save()
rpt = RearPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Rear Port 1',
type=PortTypeChoices.TYPE_8P8C,
positions=8
@@ -293,7 +295,7 @@ class DeviceTestCase(TestCase):
rpt.save()
FrontPortTemplate(
device_type=self.device_type,
device_type=device_type,
name='Front Port 1',
type=PortTypeChoices.TYPE_8P8C,
rear_port=rpt,
@@ -301,12 +303,12 @@ class DeviceTestCase(TestCase):
).save()
ModuleBayTemplate(
device_type=self.device_type,
device_type=device_type,
name='Module Bay 1'
).save()
DeviceBayTemplate(
device_type=self.device_type,
device_type=device_type,
name='Device Bay 1'
).save()
@@ -315,9 +317,9 @@ class DeviceTestCase(TestCase):
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
d.save()
@@ -381,9 +383,9 @@ class DeviceTestCase(TestCase):
def test_multiple_unnamed_devices(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name=None
)
device1.save()
@@ -402,9 +404,9 @@ class DeviceTestCase(TestCase):
def test_device_name_case_sensitivity(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='device 1'
)
device1.save()
@@ -423,9 +425,9 @@ class DeviceTestCase(TestCase):
def test_device_duplicate_names(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Test Device 1'
)
device1.save()
@@ -459,7 +461,8 @@ class DeviceTestCase(TestCase):
class CableTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -469,72 +472,76 @@ class CableTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device1 = Device.objects.create(
device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
self.device2 = Device.objects.create(
device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice2', site=site
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2])
self.cable.save()
interface1 = Interface.objects.create(device=device1, name='eth0')
interface2 = Interface.objects.create(device=device2, name='eth0')
interface3 = Interface.objects.create(device=device2, name='eth1')
Cable(a_terminations=[interface1], b_terminations=[interface2]).save()
self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1')
self.patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
power_port1 = PowerPort.objects.create(device=device2, name='psu1')
patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPanel', site=site
)
self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
self.front_port1 = FrontPort.objects.create(
device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
rear_port1 = RearPort.objects.create(device=patch_pannel, name='RP1', type='8p8c')
front_port1 = FrontPort.objects.create(
device=patch_pannel, name='FP1', type='8p8c', rear_port=rear_port1, rear_port_position=1
)
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
self.front_port2 = FrontPort.objects.create(
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
rear_port2 = RearPort.objects.create(device=patch_pannel, name='RP2', type='8p8c', positions=2)
front_port2 = FrontPort.objects.create(
device=patch_pannel, name='FP2', type='8p8c', rear_port=rear_port2, rear_port_position=1
)
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
self.front_port3 = FrontPort.objects.create(
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
rear_port3 = RearPort.objects.create(device=patch_pannel, name='RP3', type='8p8c', positions=3)
front_port3 = FrontPort.objects.create(
device=patch_pannel, name='FP3', type='8p8c', rear_port=rear_port3, rear_port_position=1
)
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
self.front_port4 = FrontPort.objects.create(
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
rear_port4 = RearPort.objects.create(device=patch_pannel, name='RP4', type='8p8c', positions=3)
front_port4 = FrontPort.objects.create(
device=patch_pannel, name='FP4', type='8p8c', rear_port=rear_port4, rear_port_position=1
)
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.provider)
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A')
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z')
self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, provider_network=provider_network, term_side='A')
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
circuit1 = Circuit.objects.create(provider=provider, type=circuittype, cid='1')
circuit2 = Circuit.objects.create(provider=provider, type=circuittype, cid='2')
circuittermination1 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=circuit1, site=site, term_side='Z')
circuittermination3 = CircuitTermination.objects.create(circuit=circuit2, provider_network=provider_network, term_side='A')
def test_cable_creation(self):
"""
When a new Cable is created, it must be cached on either termination point.
"""
self.interface1.refresh_from_db()
self.interface2.refresh_from_db()
self.assertEqual(self.interface1.cable, self.cable)
self.assertEqual(self.interface2.cable, self.cable)
self.assertEqual(self.interface1.cable_end, 'A')
self.assertEqual(self.interface2.cable_end, 'B')
self.assertEqual(self.interface1.link_peers, [self.interface2])
self.assertEqual(self.interface2.link_peers, [self.interface1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
cable = Cable.objects.first()
self.assertEqual(interface1.cable, cable)
self.assertEqual(interface2.cable, cable)
self.assertEqual(interface1.cable_end, 'A')
self.assertEqual(interface2.cable_end, 'B')
self.assertEqual(interface1.link_peers, [interface2])
self.assertEqual(interface2.link_peers, [interface1])
def test_cable_deletion(self):
"""
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
should still return the PK of the string even after being nullified.
"""
self.cable.delete()
self.assertIsNone(self.cable.pk)
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
cable = Cable.objects.first()
cable.delete()
self.assertIsNone(cable.pk)
self.assertNotEqual(str(cable), '#None')
interface1 = Interface.objects.get(pk=interface1.pk)
self.assertIsNone(interface1.cable)
self.assertListEqual(interface1.link_peers, [])
interface2 = Interface.objects.get(pk=self.interface2.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
self.assertIsNone(interface2.cable)
self.assertListEqual(interface2.link_peers, [])
@@ -542,7 +549,10 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable belong to the same parent object.
"""
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -550,7 +560,11 @@ class CableTestCase(TestCase):
"""
The clean method should ensure that all terminations at either end of a Cable are of the same type.
"""
cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1])
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
frontport1 = FrontPort.objects.get(device__name='TestPatchPanel', name='FP1')
rearport1 = RearPort.objects.get(device__name='TestPatchPanel', name='RP1')
cable = Cable(a_terminations=[frontport1, rearport1], b_terminations=[interface1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -558,8 +572,11 @@ class CableTestCase(TestCase):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
powerport1 = PowerPort.objects.get(device__name='TestDevice2', name='psu1')
# An interface cannot be connected to a power port, for example
cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1])
cable = Cable(a_terminations=[interface1], b_terminations=[powerport1])
with self.assertRaises(ValidationError):
cable.clean()
@@ -567,7 +584,10 @@ class CableTestCase(TestCase):
"""
Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork
"""
cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3])
interface3 = Interface.objects.get(device__name='TestDevice2', name='eth1')
circuittermination3 = CircuitTermination.objects.get(circuit__cid='2', term_side='A')
cable = Cable(a_terminations=[interface3], b_terminations=[circuittermination3])
with self.assertRaises(ValidationError):
cable.clean()
@@ -575,8 +595,11 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface])
device1 = Device.objects.get(name='TestDevice1')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
virtual_interface = Interface(device=device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL)
cable = Cable(a_terminations=[interface2], b_terminations=[virtual_interface])
with self.assertRaises(ValidationError):
cable.clean()
@@ -584,15 +607,19 @@ class CableTestCase(TestCase):
"""
A cable cannot terminate to a wireless interface
"""
wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
device1 = Device.objects.get(name='TestDevice1')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
wireless_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A)
cable = Cable(a_terminations=[interface2], b_terminations=[wireless_interface])
with self.assertRaises(ValidationError):
cable.clean()
class VirtualDeviceContextTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -602,36 +629,41 @@ class VirtualDeviceContextTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
)
def test_vdc_and_interface_creation(self):
device = Device.objects.first()
vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
vdc = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc.full_clean()
vdc.save()
interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
interface = Interface(device=device, name='Eth1/1', type='10gbase-t')
interface.full_clean()
interface.save()
interface.vdcs.set([vdc])
def test_vdc_duplicate_name(self):
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
device = Device.objects.first()
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc1.full_clean()
vdc1.save()
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
vdc2 = VirtualDeviceContext(device=device, name="VDC 1", identifier=2, status='active')
with self.assertRaises(ValidationError):
vdc2.full_clean()
def test_vdc_duplicate_identifier(self):
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
device = Device.objects.first()
vdc1 = VirtualDeviceContext(device=device, name="VDC 1", identifier=1, status='active')
vdc1.full_clean()
vdc1.save()
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
vdc2 = VirtualDeviceContext(device=device, name="VDC 2", identifier=1, status='active')
with self.assertRaises(ValidationError):
vdc2.full_clean()

View File

@@ -5,7 +5,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
class NaturalOrderingTestCase(TestCase):
def setUp(self):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -15,12 +16,12 @@ class NaturalOrderingTestCase(TestCase):
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
def test_interface_ordering_numeric(self):
device = Device.objects.first()
INTERFACES = [
'0',
'0.0',
@@ -57,16 +58,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_linux(self):
device = Device.objects.first()
INTERFACES = [
'eth0',
'eth0.1',
@@ -81,16 +82,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_junos(self):
device = Device.objects.first()
INTERFACES = [
'xe-0/0/0',
'xe-0/0/1',
@@ -134,16 +135,16 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)
def test_interface_ordering_ios(self):
device = Device.objects.first()
INTERFACES = [
'GigabitEthernet0/1',
'GigabitEthernet0/2',
@@ -161,10 +162,10 @@ class NaturalOrderingTestCase(TestCase):
]
for name in INTERFACES:
iface = Interface(device=self.device, name=name)
iface = Interface(device=device, name=name)
iface.save()
self.assertListEqual(
list(Interface.objects.filter(device=self.device).values_list('name', flat=True)),
list(Interface.objects.filter(device=device).values_list('name', flat=True)),
INTERFACES
)

View File

@@ -17,6 +17,7 @@ from dcim.constants import *
from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF
from tenancy.models import Tenant
from utilities.choices import ImportFormatChoices
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN
@@ -388,15 +389,18 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'weight': 100,
'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"site,location,name,status,width,u_height",
"Site 1,,Rack 4,active,19,42",
"Site 1,Location 1,Rack 5,active,19,42",
"Site 2,Location 2,Rack 6,active,19,42",
"site,location,name,status,width,u_height,weight,max_weight,weight_unit",
"Site 1,,Rack 4,active,19,42,100,2000,kg",
"Site 1,Location 1,Rack 5,active,19,42,100,2000,kg",
"Site 2,Location 2,Rack 6,active,19,42,100,2000,kg",
)
cls.csv_update_data = (
@@ -420,6 +424,9 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_width': 30,
'outer_depth': 30,
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
'weight': 200,
'max_weight': 4000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'New comments',
}
@@ -1887,26 +1894,28 @@ class ModuleTestCase(
'device': devices[0].pk,
'module_bay': module_bays[3].pk,
'module_type': module_types[0].pk,
'status': ModuleStatusChoices.STATUS_ACTIVE,
'serial': 'A',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'module_type': module_types[3].pk,
'status': ModuleStatusChoices.STATUS_PLANNED,
}
cls.csv_data = (
"device,module_bay,module_type,serial,asset_tag",
"Device 2,Module Bay 1,Module Type 1,A,A",
"Device 2,Module Bay 2,Module Type 2,B,B",
"Device 2,Module Bay 3,Module Type 3,C,C",
"device,module_bay,module_type,status,serial,asset_tag",
"Device 2,Module Bay 1,Module Type 1,active,A,A",
"Device 2,Module Bay 2,Module Type 2,planned,B,B",
"Device 2,Module Bay 3,Module Type 3,failed,C,C",
)
cls.csv_update_data = (
"id,serial",
f"{modules[0].pk},Serial 2",
f"{modules[1].pk},Serial 3",
f"{modules[2].pk},Serial 1",
"id,status,serial",
f"{modules[0].pk},offline,Serial 2",
f"{modules[1].pk},offline,Serial 3",
f"{modules[2].pk},offline,Serial 1",
)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@@ -1942,6 +1951,54 @@ class ModuleTestCase(
self.assertHttpStatus(self.client.post(**request), 302)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_bulk_replication(self):
self.add_permissions('dcim.add_module')
# Add 5 InterfaceTemplates to a ModuleType
module_type = ModuleType.objects.first()
interface_templates = [
InterfaceTemplate(module_type=module_type, name=f'Interface {i}')
for i in range(1, 6)
]
InterfaceTemplate.objects.bulk_create(interface_templates)
# Create a module *without* replicating components
device = Device.objects.get(name='Device 2')
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
csv_data = [
"device,module_bay,module_type,status,replicate_components",
f"{device.name},{module_bay.name},{module_type.model},active,false"
]
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = Module.objects.count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
self.assertEqual(Interface.objects.filter(device=device).count(), 0)
# Create a second module (in the next bay) with replicated components
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 5')
csv_data[1] = f"{device.name},{module_bay.name},{module_type.model},active,true"
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = Module.objects.count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(Module.objects.count(), initial_count + len(csv_data) - 1)
self.assertEqual(Interface.objects.filter(device=device).count(), 5)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_component_adoption(self):
self.add_permissions('dcim.add_module')
@@ -1979,6 +2036,50 @@ class ModuleTestCase(
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_module_bulk_adoption(self):
self.add_permissions('dcim.add_module')
interface_name = "Interface-1"
# Add an interface to the ModuleType
module_type = ModuleType.objects.first()
InterfaceTemplate(module_type=module_type, name=interface_name).save()
form_data = self.form_data.copy()
device = Device.objects.get(pk=form_data['device'])
# Create an interface to be adopted
interface = Interface(device=device, name=interface_name, type=InterfaceTypeChoices.TYPE_10GE_FIXED)
interface.save()
# Ensure that interface is created with no module
self.assertIsNone(interface.module)
# Create a module with adopted components
module_bay = ModuleBay.objects.get(device=device, name='Module Bay 4')
csv_data = [
"device,module_bay,module_type,status,replicate_components,adopt_components",
f"{device.name},{module_bay.name},{module_type.model},active,false,true"
]
request = {
'path': self._get_url('import'),
'data': {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
}
}
initial_count = self._get_queryset().count()
self.assertHttpStatus(self.client.post(**request), 200)
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
# Re-retrieve interface to get new module id
interface.refresh_from_db()
# Check that the Interface now has a module
self.assertIsNotNone(interface.module)
class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = ConsolePort

View File

@@ -177,7 +177,6 @@ urlpatterns = [
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),

View File

@@ -21,7 +21,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
from .choices import DeviceFaceChoices
from .constants import NONCONNECTABLE_IFACE_TYPES
@@ -391,7 +393,7 @@ class SiteView(generic.ObjectView):
scope_id=instance.pk
).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct().count(),
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(),
}
locations = Location.objects.add_related_count(
@@ -640,6 +642,7 @@ class RackListView(generic.ObjectListView):
filterset = filtersets.RackFilterSet
filterset_form = forms.RackFilterForm
table = tables.RackTable
template_name = 'dcim/rack_list.html'
class RackElevationListView(generic.ObjectListView):
@@ -653,17 +656,18 @@ class RackElevationListView(generic.ObjectListView):
racks = filtersets.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Ordering
ORDERING_CHOICES = {
'name': 'Name (A-Z)',
'-name': 'Name (Z-A)',
'facility_id': 'Facility ID (A-Z)',
'-facility_id': 'Facility ID (Z-A)',
}
sort = request.GET.get('sort', "name")
sort = request.GET.get('sort', 'name')
if sort not in ORDERING_CHOICES:
sort = 'name'
racks = racks.order_by(sort)
sort_field = sort.replace("name", "_name") # Use natural ordering
racks = racks.order_by(sort_field)
# Pagination
per_page = get_paginate_count(request)
@@ -690,6 +694,7 @@ class RackElevationListView(generic.ObjectListView):
'sort_choices': ORDERING_CHOICES,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
'model': self.queryset.model,
})
@@ -838,6 +843,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
class ManufacturerListView(generic.ObjectListView):
queryset = Manufacturer.objects.annotate(
devicetype_count=count_related(DeviceType, 'manufacturer'),
moduletype_count=count_related(ModuleType, 'manufacturer'),
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
platform_count=count_related(Platform, 'manufacturer')
)
@@ -952,6 +958,7 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(),
permission='dcim.view_consoleporttemplate',
weight=550,
hide_if_empty=True
)
@@ -966,6 +973,7 @@ class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(),
permission='dcim.view_consoleserverporttemplate',
weight=560,
hide_if_empty=True
)
@@ -980,6 +988,7 @@ class DeviceTypePowerPortsView(DeviceTypeComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(),
permission='dcim.view_powerporttemplate',
weight=570,
hide_if_empty=True
)
@@ -994,6 +1003,7 @@ class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(),
permission='dcim.view_poweroutlettemplate',
weight=580,
hide_if_empty=True
)
@@ -1008,6 +1018,7 @@ class DeviceTypeInterfacesView(DeviceTypeComponentsView):
label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(),
permission='dcim.view_interfacetemplate',
weight=520,
hide_if_empty=True
)
@@ -1022,6 +1033,7 @@ class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(),
permission='dcim.view_frontporttemplate',
weight=530,
hide_if_empty=True
)
@@ -1036,6 +1048,7 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(),
permission='dcim.view_rearporttemplate',
weight=540,
hide_if_empty=True
)
@@ -1050,6 +1063,7 @@ class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
label=_('Module Bays'),
badge=lambda obj: obj.modulebaytemplates.count(),
permission='dcim.view_modulebaytemplate',
weight=510,
hide_if_empty=True
)
@@ -1064,6 +1078,7 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
label=_('Device Bays'),
badge=lambda obj: obj.devicebaytemplates.count(),
permission='dcim.view_devicebaytemplate',
weight=500,
hide_if_empty=True
)
@@ -1078,6 +1093,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitemtemplates.count(),
permission='dcim.view_invenotryitemtemplate',
weight=590,
hide_if_empty=True
)
@@ -1180,6 +1196,7 @@ class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleporttemplates.count(),
permission='dcim.view_consoleporttemplate',
weight=530,
hide_if_empty=True
)
@@ -1194,6 +1211,7 @@ class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverporttemplates.count(),
permission='dcim.view_consoleserverporttemplate',
weight=540,
hide_if_empty=True
)
@@ -1208,6 +1226,7 @@ class ModuleTypePowerPortsView(ModuleTypeComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerporttemplates.count(),
permission='dcim.view_powerporttemplate',
weight=550,
hide_if_empty=True
)
@@ -1222,6 +1241,7 @@ class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlettemplates.count(),
permission='dcim.view_poweroutlettemplate',
weight=560,
hide_if_empty=True
)
@@ -1236,6 +1256,7 @@ class ModuleTypeInterfacesView(ModuleTypeComponentsView):
label=_('Interfaces'),
badge=lambda obj: obj.interfacetemplates.count(),
permission='dcim.view_interfacetemplate',
weight=500,
hide_if_empty=True
)
@@ -1250,6 +1271,7 @@ class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontporttemplates.count(),
permission='dcim.view_frontporttemplate',
weight=510,
hide_if_empty=True
)
@@ -1264,6 +1286,7 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearporttemplates.count(),
permission='dcim.view_rearporttemplate',
weight=520,
hide_if_empty=True
)
@@ -1717,6 +1740,42 @@ class DeviceRoleView(generic.ObjectView):
}
@register_model_view(DeviceRole, 'devices', path='devices')
class DeviceRoleDevicesView(generic.ObjectChildrenView):
queryset = DeviceRole.objects.all()
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
template_name = 'dcim/devicerole/devices.html'
tab = ViewTab(
label=_('Devices'),
badge=lambda obj: obj.devices.count(),
permission='dcim.view_device',
weight=400
)
def get_children(self, request, parent):
return Device.objects.restrict(request.user, 'view').filter(device_role=parent)
@register_model_view(DeviceRole, 'virtual_machines', path='virtual-machines')
class DeviceRoleVirtualMachinesView(generic.ObjectChildrenView):
queryset = DeviceRole.objects.all()
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
template_name = 'dcim/devicerole/virtual_machines.html'
tab = ViewTab(
label=_('Virtual machines'),
badge=lambda obj: obj.virtual_machines.count(),
permission='virtualization.view_virtualmachine',
weight=500
)
def get_children(self, request, parent):
return VirtualMachine.objects.restrict(request.user, 'view').filter(role=parent)
@register_model_view(DeviceRole, 'edit')
class DeviceRoleEditView(generic.ObjectEditView):
queryset = DeviceRole.objects.all()
@@ -1872,6 +1931,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
label=_('Console Ports'),
badge=lambda obj: obj.consoleports.count(),
permission='dcim.view_consoleport',
weight=550,
hide_if_empty=True
)
@@ -1886,6 +1946,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
label=_('Console Server Ports'),
badge=lambda obj: obj.consoleserverports.count(),
permission='dcim.view_consoleserverport',
weight=560,
hide_if_empty=True
)
@@ -1900,6 +1961,7 @@ class DevicePowerPortsView(DeviceComponentsView):
label=_('Power Ports'),
badge=lambda obj: obj.powerports.count(),
permission='dcim.view_powerport',
weight=570,
hide_if_empty=True
)
@@ -1914,6 +1976,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
label=_('Power Outlets'),
badge=lambda obj: obj.poweroutlets.count(),
permission='dcim.view_poweroutlet',
weight=580,
hide_if_empty=True
)
@@ -1926,8 +1989,9 @@ class DeviceInterfacesView(DeviceComponentsView):
template_name = 'dcim/device/interfaces.html'
tab = ViewTab(
label=_('Interfaces'),
badge=lambda obj: obj.interfaces.count(),
badge=lambda obj: obj.vc_interfaces().count(),
permission='dcim.view_interface',
weight=520,
hide_if_empty=True
)
@@ -1948,6 +2012,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
label=_('Front Ports'),
badge=lambda obj: obj.frontports.count(),
permission='dcim.view_frontport',
weight=530,
hide_if_empty=True
)
@@ -1962,6 +2027,7 @@ class DeviceRearPortsView(DeviceComponentsView):
label=_('Rear Ports'),
badge=lambda obj: obj.rearports.count(),
permission='dcim.view_rearport',
weight=540,
hide_if_empty=True
)
@@ -1976,6 +2042,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
label=_('Module Bays'),
badge=lambda obj: obj.modulebays.count(),
permission='dcim.view_modulebay',
weight=510,
hide_if_empty=True
)
@@ -1990,6 +2057,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
label=_('Device Bays'),
badge=lambda obj: obj.devicebays.count(),
permission='dcim.view_devicebay',
weight=500,
hide_if_empty=True
)
@@ -2004,6 +2072,7 @@ class DeviceInventoryView(DeviceComponentsView):
label=_('Inventory Items'),
badge=lambda obj: obj.inventoryitems.count(),
permission='dcim.view_inventoryitem',
weight=590,
hide_if_empty=True
)
@@ -2014,7 +2083,8 @@ class DeviceConfigContextView(ObjectConfigContextView):
base_template = 'dcim/device/base.html'
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext'
permission='extras.view_configcontext',
weight=2000
)
@@ -2022,22 +2092,15 @@ class DeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
class ChildDeviceBulkImportView(generic.BulkImportView):
queryset = Device.objects.all()
model_form = forms.ChildDeviceImportForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
def save_object(self, object_form, request):
obj = object_form.save()
# Save the reverse relation to the parent device bay
device_bay = obj.parent_bay
device_bay.installed_device = obj
device_bay.save()
# For child devices, save the reverse relation to the parent device bay
if getattr(obj, 'parent_bay', None):
device_bay = obj.parent_bay
device_bay.installed_device = obj
device_bay.save()
return obj
@@ -2072,6 +2135,7 @@ class NAPALMViewTab(ViewTab):
if not (
instance.status == 'active' and
instance.primary_ip and
instance.platform and
instance.platform.napalm_driver
):
return None
@@ -2086,6 +2150,7 @@ class DeviceStatusView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('Status'),
permission='dcim.napalm_read_device',
weight=3000
)
@@ -2097,6 +2162,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('LLDP Neighbors'),
permission='dcim.napalm_read_device',
weight=3100
)
def get_extra_context(self, request, instance):
@@ -2119,6 +2185,7 @@ class DeviceConfigView(generic.ObjectView):
tab = NAPALMViewTab(
label=_('Config'),
permission='dcim.napalm_read_device',
weight=3200
)
@@ -2786,7 +2853,7 @@ class DeviceBayPopulateView(generic.ObjectEditView):
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
if form.is_valid():
device_bay.snapshot()
device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
@@ -2820,7 +2887,7 @@ class DeviceBayDepopulateView(generic.ObjectEditView):
form = ConfirmationForm(request.POST)
if form.is_valid():
device_bay.snapshot()
removed_device = device_bay.installed_device
device_bay.installed_device = None
device_bay.save()
@@ -2880,23 +2947,14 @@ class InventoryItemView(generic.ObjectView):
class InventoryItemEditView(generic.ObjectEditView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemForm
template_name = 'dcim/inventoryitem_edit.html'
class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
def alter_object(self, instance, request):
# Set component (if any)
component_type = request.GET.get('component_type')
component_id = request.GET.get('component_id')
if component_type and component_id:
content_type = get_object_or_404(ContentType, pk=component_type)
instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
return instance
template_name = 'dcim/inventoryitem_edit.html'
@register_model_view(InventoryItem, 'delete')
@@ -3578,7 +3636,9 @@ register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceVi
# VDC
class VirtualDeviceContextListView(generic.ObjectListView):
queryset = VirtualDeviceContext.objects.all()
queryset = VirtualDeviceContext.objects.annotate(
interface_count=count_related(Interface, 'vdcs'),
)
filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable
@@ -3591,6 +3651,7 @@ class VirtualDeviceContextView(generic.ObjectView):
def get_extra_context(self, request, instance):
interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
interfaces_table.configure(request)
interfaces_table.columns.hide('device')
return {
'interfaces_table': interfaces_table,

View File

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

View File

@@ -385,8 +385,8 @@ class JobResultSerializer(BaseModelSerializer):
class Meta:
model = JobResult
fields = [
'id', 'url', 'display', 'status', 'created', 'scheduled', 'started', 'completed', 'name', 'obj_type',
'user', 'data', 'job_id',
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
'obj_type', 'user', 'data', 'job_id',
]
@@ -414,6 +414,7 @@ class ReportDetailSerializer(ReportSerializer):
class ReportInputSerializer(serializers.Serializer):
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
#
@@ -448,6 +449,7 @@ class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
schedule_at = serializers.DateTimeField(required=False, allow_null=True)
interval = serializers.IntegerField(required=False, allow_null=True)
class ScriptLogMessageSerializer(serializers.Serializer):

View File

@@ -1,5 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.http import Http404
from django_rq.queues import get_connection
from rest_framework import status
@@ -246,16 +245,14 @@ class ReportViewSet(ViewSet):
input_serializer = serializers.ReportInputSerializer(data=request.data)
if input_serializer.is_valid():
schedule_at = input_serializer.validated_data.get('schedule_at')
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
request.user,
name=report.full_name,
obj_type=ContentType.objects.get_for_model(Report),
user=request.user,
job_timeout=report.job_timeout,
schedule_at=schedule_at,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
)
report.result = job_result
@@ -321,6 +318,10 @@ class ScriptViewSet(ViewSet):
"""
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
"""
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
script = self._get_script(pk)()
input_serializer = serializers.ScriptInputSerializer(data=request.data)
@@ -329,21 +330,17 @@ class ScriptViewSet(ViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
data = input_serializer.data['data']
commit = input_serializer.data['commit']
schedule_at = input_serializer.validated_data.get('schedule_at')
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job(
run_script,
script.full_name,
script_content_type,
request.user,
data=data,
name=script.full_name,
obj_type=ContentType.objects.get_for_model(Script),
user=request.user,
data=input_serializer.data['data'],
request=copy_safe_request(request),
commit=commit,
commit=input_serializer.data['commit'],
job_timeout=script.job_timeout,
schedule_at=schedule_at,
schedule_at=input_serializer.validated_data.get('schedule_at'),
interval=input_serializer.validated_data.get('interval')
)
script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})

View File

@@ -148,12 +148,12 @@ class JobResultStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed'
CHOICES = (
(STATUS_PENDING, 'Pending'),
(STATUS_SCHEDULED, 'Scheduled'),
(STATUS_RUNNING, 'Running'),
(STATUS_COMPLETED, 'Completed'),
(STATUS_ERRORED, 'Errored'),
(STATUS_FAILED, 'Failed'),
(STATUS_PENDING, 'Pending', 'cyan'),
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
(STATUS_RUNNING, 'Running', 'blue'),
(STATUS_COMPLETED, 'Completed', 'green'),
(STATUS_ERRORED, 'Errored', 'red'),
(STATUS_FAILED, 'Failed', 'red'),
)
TERMINAL_STATE_CHOICES = (

View File

@@ -44,7 +44,8 @@ class Condition:
bool: (EQ, CONTAINS),
int: (EQ, GT, GTE, LT, LTE, CONTAINS),
float: (EQ, GT, GTE, LT, LTE, CONTAINS),
list: (EQ, IN, CONTAINS)
list: (EQ, IN, CONTAINS),
type(None): (EQ,)
}
def __init__(self, attr, value, op=EQ, negate=False):

8
netbox/extras/fields.py Normal file
View File

@@ -0,0 +1,8 @@
from django.db.models import TextField
class CachedValueField(TextField):
"""
Currently a dummy field to prevent custom lookups being applied globally to TextField.
"""
pass

View File

@@ -17,10 +17,10 @@ __all__ = (
'ConfigContextFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'JobResultFilterSet',
'CustomLinkFilterSet',
'ExportTemplateFilterSet',
'ImageAttachmentFilterSet',
'JobResultFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'ObjectChangeFilterSet',
@@ -537,7 +537,7 @@ class JobResultFilterSet(BaseFilterSet):
class Meta:
model = JobResult
fields = ('id', 'status', 'user', 'obj_type', 'name')
fields = ('id', 'interval', 'status', 'user', 'obj_type', 'name')
def search(self, queryset, name, value):
if not value.strip():

View File

@@ -38,8 +38,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
required=False,
label=_('Object type')
)
@@ -79,8 +78,7 @@ class JobResultFilterForm(SavedFiltersMixin, FilterForm):
)
obj_type = ContentTypeChoiceField(
label=_('Object Type'),
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('job_results'), # TODO: This doesn't actually work
queryset=ContentType.objects.filter(FeatureQuery('job_results').get_query()),
required=False,
)
status = MultipleChoiceField(
@@ -135,8 +133,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
('Attributes', ('content_types', 'enabled', 'new_window', 'weight')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_links'),
queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()),
required=False
)
enabled = forms.NullBooleanField(
@@ -162,8 +159,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
('Attributes', ('content_types', 'mime_type', 'file_extension', 'as_attachment')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
required=False
)
mime_type = forms.CharField(
@@ -187,8 +183,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
('Attributes', ('content_types', 'enabled', 'shared', 'weight')),
)
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('export_templates'),
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
required=False
)
enabled = forms.NullBooleanField(
@@ -215,8 +210,7 @@ class WebhookFilterForm(SavedFiltersMixin, FilterForm):
('Events', ('type_create', 'type_update', 'type_delete')),
)
content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('webhooks'),
queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()),
required=False,
label=_('Object type')
)

View File

@@ -32,7 +32,6 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'),
label=_('Model(s)')
)
object_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(),
@@ -116,6 +115,7 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
content_types = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.all()
)
parameters = JSONField()
fieldsets = (
('Saved Filter', ('name', 'slug', 'content_types', 'description', 'weight', 'enabled', 'shared')),
@@ -125,9 +125,6 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = SavedFilter
exclude = ('user',)
widgets = {
'parameters': forms.Textarea(attrs={'class': 'font-monospace'}),
}
def __init__(self, *args, initial=None, **kwargs):

View File

@@ -1,7 +1,9 @@
from django import forms
from django.utils import timezone
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
from utilities.utils import local_now
__all__ = (
'ReportForm',
@@ -15,3 +17,24 @@ class ReportForm(BootstrapMixin, forms.Form):
label=_("Schedule at"),
help_text=_("Schedule execution of report to a set time"),
)
interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
widget=SelectDurationWidget(),
help_text=_("Interval at which this report is re-run (in minutes)")
)
def clean_schedule_at(self):
scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < timezone.now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'

View File

@@ -1,7 +1,8 @@
from django import forms
from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin, DateTimePicker
from utilities.forms import BootstrapMixin, DateTimePicker, SelectDurationWidget
from utilities.utils import local_now
__all__ = (
'ScriptForm',
@@ -21,19 +22,43 @@ class ScriptForm(BootstrapMixin, forms.Form):
label=_("Schedule at"),
help_text=_("Schedule execution of script to a set time"),
)
_interval = forms.IntegerField(
required=False,
min_value=1,
label=_("Recurs every"),
widget=SelectDurationWidget(),
help_text=_("Interval at which this script is re-run (in minutes)")
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
self.fields['_schedule_at'].help_text += f' (current time: <strong>{now}</strong>)'
# Move _commit and _schedule_at to the end of the form
schedule_at = self.fields.pop('_schedule_at')
interval = self.fields.pop('_interval')
commit = self.fields.pop('_commit')
self.fields['_schedule_at'] = schedule_at
self.fields['_interval'] = interval
self.fields['_commit'] = commit
def clean(self):
scheduled_time = self.cleaned_data['_schedule_at']
if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.'))
# When interval is used without schedule at, raise an exception
if self.cleaned_data['_interval'] and not scheduled_time:
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.'))
return self.cleaned_data
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit and _schedule_at fields).
A boolean indicating whether the form requires user input (ignore the built-in fields).
"""
return bool(len(self.fields) > 2)
return bool(len(self.fields) > 3)

View File

@@ -1,4 +1,5 @@
from django.db.models import CharField, Lookup
from django.db.models import CharField, TextField, Lookup
from .fields import CachedValueField
class Empty(Lookup):
@@ -14,4 +15,18 @@ class Empty(Lookup):
return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
class NetContainsOrEquals(Lookup):
"""
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
"""
lookup_name = 'net_contains_or_equals'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'CAST(%s AS INET) >>= %s' % (lhs, rhs), params
CharField.register_lookup(Empty)
CachedValueField.register_lookup(NetContainsOrEquals)

View File

@@ -37,7 +37,7 @@ class Command(BaseCommand):
f"clearing sessions; skipping."
)
# Delete expired ObjectRecords
# Delete expired ObjectChanges
if options['verbosity']:
self.stdout.write("[*] Checking for expired changelog records")
if config.CHANGELOG_RETENTION:

View File

@@ -15,6 +15,11 @@ class Command(BaseCommand):
nargs='*',
help='One or more apps or models to reindex',
)
parser.add_argument(
'--lazy',
action='store_true',
help="For each model, reindex objects only if no cache entries already exist"
)
def _get_indexers(self, *model_names):
indexers = {}
@@ -27,17 +32,28 @@ class Command(BaseCommand):
# Return only indexers for the specified models
else:
for label in model_names:
try:
app_label, model_name = label.lower().split('.')
except ValueError:
labels = label.lower().split('.')
# Label specifies an exact model
if len(labels) == 2:
app_label, model_name = labels
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
# Label specifies all the models of an app
elif len(labels) == 1:
app_label = labels[0] + '.'
for indexer_label, idx in registry['search'].items():
if indexer_label.startswith(app_label):
indexers[idx.model] = idx
else:
raise CommandError(
f"Invalid model: {label}. Model names must be in the format <app_label>.<model_name>."
f"Invalid model: {label}. Model names must be in the format <app_label> or <app_label>.<model_name>."
)
try:
idx = registry['search'][f'{app_label}.{model_name}']
indexers[idx.model] = idx
except KeyError:
raise CommandError(f"No indexer registered for {label}")
return indexers
@@ -49,14 +65,15 @@ class Command(BaseCommand):
raise CommandError("No indexers found!")
self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models
self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush()
content_types = [
ContentType.objects.get_for_model(model) for model in indexers.keys()
]
deleted_count = search_backend.clear(content_types)
self.stdout.write(f'{deleted_count} entries deleted.')
# Clear all cached values for the specified models (if not being lazy)
if not kwargs['lazy']:
self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush()
content_types = [
ContentType.objects.get_for_model(model) for model in indexers.keys()
]
deleted_count = search_backend.clear(content_types)
self.stdout.write(f'{deleted_count} entries deleted.')
# Index models
self.stdout.write('Indexing models')
@@ -65,11 +82,18 @@ class Command(BaseCommand):
model_name = model._meta.model_name
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
self.stdout.flush()
if kwargs['lazy']:
content_type = ContentType.objects.get_for_model(model)
if cached_count := search_backend.count(object_types=[content_type]):
self.stdout.write(f'Skipping (found {cached_count} existing).')
continue
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
if i:
self.stdout.write(f'{i} entries cached.')
else:
self.stdout.write(f'None found.')
self.stdout.write(f'No objects found.')
msg = f'Completed.'
if total_count := search_backend.size:

View File

@@ -20,7 +20,7 @@ from utilities.utils import NetBoxFakeRequest
class Command(BaseCommand):
help = "Run a script in Netbox"
help = "Run a script in NetBox"
def add_arguments(self, parser):
parser.add_argument(

View File

@@ -1,3 +1,4 @@
import django.core.validators
from django.db import migrations, models
@@ -13,6 +14,11 @@ class Migration(migrations.Migration):
name='scheduled',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='jobresult',
name='interval',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='jobresult',
name='started',

View File

@@ -1,15 +1,9 @@
import sys
import uuid
import django.db.models.deletion
from django.core import management
import django.db.models.lookups
from django.db import migrations, models
def reindex(apps, schema_editor):
# Build the search index (except during tests)
if 'test' not in sys.argv:
management.call_command('reindex')
import extras.fields
class Migration(migrations.Migration):
@@ -39,7 +33,7 @@ class Migration(migrations.Migration):
('object_id', models.PositiveBigIntegerField()),
('field', models.CharField(max_length=200)),
('type', models.CharField(max_length=30)),
('value', models.TextField(db_index=True)),
('value', extras.fields.CachedValueField()),
('weight', models.PositiveSmallIntegerField(default=1000)),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='contenttypes.contenttype')),
],
@@ -47,8 +41,4 @@ class Migration(migrations.Migration):
'ordering': ('weight', 'object_type', 'object_id'),
},
),
migrations.RunPython(
code=reindex,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -20,10 +20,12 @@ from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.forms import (
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
from utilities.forms.fields import (
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
)
from utilities.forms.widgets import DatePicker, StaticSelectMultiple, StaticSelect
from utilities.forms.utils import add_blank_choice
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@@ -273,10 +275,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
'choices': "Choices may be set only for custom selection fields."
})
# A selection field must have at least two choices defined
if self.type == CustomFieldTypeChoices.TYPE_SELECT and self.choices and len(self.choices) < 2:
# Selection fields must have at least one choice defined
if self.type in (
CustomFieldTypeChoices.TYPE_SELECT,
CustomFieldTypeChoices.TYPE_MULTISELECT
) and not self.choices:
raise ValidationError({
'choices': "Selection fields must specify at least two choices."
'choices': "Selection fields must specify at least one choice."
})
# A selection field's default (if any) must be present in its available choices
@@ -410,7 +415,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
model = self.object_type.model_class()
field = DynamicModelChoiceField(
field_class = CSVModelChoiceField if for_csv_import else DynamicModelChoiceField
field = field_class(
queryset=model.objects.all(),
required=required,
initial=initial
@@ -419,10 +425,11 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
# Multiple objects
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
model = self.object_type.model_class()
field = DynamicModelMultipleChoiceField(
field_class = CSVModelMultipleChoiceField if for_csv_import else DynamicModelMultipleChoiceField
field = field_class(
queryset=model.objects.all(),
required=required,
initial=initial
initial=initial,
)
# Text

View File

@@ -7,7 +7,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.validators import ValidationError
from django.core.validators import MinValueValidator, ValidationError
from django.db import models
from django.http import HttpResponse, QueryDict
from django.urls import reverse
@@ -21,6 +21,8 @@ from extras.choices import *
from extras.constants import *
from extras.conditions import ConditionSet
from extras.utils import FeatureQuery, image_upload
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.models import ChangeLoggedModel
from netbox.models.features import (
CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin,
@@ -512,7 +514,7 @@ class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
return objectchange
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ChangeLoggedModel):
class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin, ExportTemplatesMixin, ChangeLoggedModel):
"""
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@@ -585,6 +587,14 @@ class JobResult(models.Model):
null=True,
blank=True
)
interval = models.PositiveIntegerField(
blank=True,
null=True,
validators=(
MinValueValidator(1),
),
help_text=_("Recurrence interval (in minutes)")
)
started = models.DateTimeField(
null=True,
blank=True
@@ -624,7 +634,8 @@ class JobResult(models.Model):
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
queue = django_rq.get_queue("default")
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
job = queue.fetch_job(str(self.job_id))
if job:
@@ -633,12 +644,20 @@ class JobResult(models.Model):
def get_absolute_url(self):
return reverse(f'extras:{self.obj_type.name}_result', args=[self.pk])
def get_status_color(self):
return JobResultStatusChoices.colors.get(self.status)
@property
def duration(self):
if not self.completed:
return None
duration = self.completed - self.created
start_time = self.started or self.created
if not start_time:
return None
duration = self.completed - start_time
minutes, seconds = divmod(duration.total_seconds(), 60)
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
@@ -662,32 +681,32 @@ class JobResult(models.Model):
self.completed = timezone.now()
@classmethod
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, *args, **kwargs):
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
"""
Create a JobResult instance and enqueue a job using the given callable
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
schedule_at: Schedule the job to be executed at the passed date and time
args: additional args passed to the callable
kwargs: additional kargs passed to the callable
Args:
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
schedule_at: Schedule the job to be executed at the passed date and time
interval: Recurrence interval (in minutes)
"""
job_result: JobResult = cls.objects.create(
rq_queue_name = get_config().QUEUE_MAPPINGS.get(obj_type.name, RQ_QUEUE_DEFAULT)
queue = django_rq.get_queue(rq_queue_name)
status = JobResultStatusChoices.STATUS_SCHEDULED if schedule_at else JobResultStatusChoices.STATUS_PENDING
job_result: JobResult = JobResult.objects.create(
name=name,
status=status,
obj_type=obj_type,
scheduled=schedule_at,
interval=interval,
user=user,
job_id=uuid.uuid4()
)
queue = django_rq.get_queue("default")
if schedule_at:
job_result.status = JobResultStatusChoices.STATUS_SCHEDULED
job_result.scheduled = schedule_at
job_result.save()
queue.enqueue_at(schedule_at, func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
else:
queue.enqueue(func, job_id=str(job_result.job_id), job_result=job_result, **kwargs)

View File

@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
from utilities.fields import RestrictedGenericForeignKey
from ..fields import CachedValueField
__all__ = (
'CachedValue',
@@ -36,9 +37,7 @@ class CachedValue(models.Model):
type = models.CharField(
max_length=30
)
value = models.TextField(
db_index=True
)
value = CachedValueField()
weight = models.PositiveSmallIntegerField(
default=1000
)

View File

@@ -1,4 +1,5 @@
import collections
from importlib import import_module
from django.apps import AppConfig
from django.conf import settings
@@ -21,6 +22,15 @@ registry['plugins'] = {
'template_extensions': collections.defaultdict(list),
}
DEFAULT_RESOURCE_PATHS = {
'search_indexes': 'search.indexes',
'graphql_schema': 'graphql.schema',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
'user_preferences': 'preferences.preferences',
}
#
# Plugin AppConfig class
@@ -58,58 +68,53 @@ class PluginConfig(AppConfig):
# Django apps to append to INSTALLED_APPS when plugin requires them.
django_apps = []
# Default integration paths. Plugin authors can override these to customize the paths to
# integrated components.
search_indexes = 'search.indexes'
graphql_schema = 'graphql.schema'
menu = 'navigation.menu'
menu_items = 'navigation.menu_items'
template_extensions = 'template_content.template_extensions'
user_preferences = 'preferences.preferences'
# Optional plugin resources
search_indexes = None
graphql_schema = None
menu = None
menu_items = None
template_extensions = None
user_preferences = None
def _load_resource(self, name):
# Import from the configured path, if defined.
if getattr(self, name):
return import_string(f"{self.__module__}.{self.name}")
# Fall back to the resource's default path. Return None if the module has not been provided.
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
default_module, resource_name = default_path.rsplit('.', 1)
try:
module = import_module(default_module)
return getattr(module, resource_name, None)
except ModuleNotFoundError:
pass
def ready(self):
plugin_name = self.name.rsplit('.', 1)[-1]
# Register search extensions (if defined)
try:
search_indexes = import_string(f"{self.__module__}.{self.search_indexes}")
for idx in search_indexes:
register_search(idx)
except ImportError:
pass
search_indexes = self._load_resource('search_indexes') or []
for idx in search_indexes:
register_search(idx)
# Register template content (if defined)
try:
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
if template_extensions := self._load_resource('template_extensions'):
register_template_extensions(template_extensions)
except ImportError:
pass
# Register navigation menu and/or menu items (if defined)
try:
menu = import_string(f"{self.__module__}.{self.menu}")
if menu := self._load_resource('menu'):
register_menu(menu)
except ImportError:
pass
try:
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
if menu_items := self._load_resource('menu_items'):
register_menu_items(self.verbose_name, menu_items)
except ImportError:
pass
# Register GraphQL schema (if defined)
try:
graphql_schema = import_string(f"{self.__module__}.{self.graphql_schema}")
if graphql_schema := self._load_resource('graphql_schema'):
register_graphql_schema(graphql_schema)
except ImportError:
pass
# Register user preferences (if defined)
try:
user_preferences = import_string(f"{self.__module__}.{self.user_preferences}")
if user_preferences := self._load_resource('user_preferences'):
register_user_preferences(plugin_name, user_preferences)
except ImportError:
pass
@classmethod
def validate(cls, user_config, netbox_version):

View File

@@ -19,6 +19,10 @@ class PluginMenu:
if icon_class is not None:
self.icon_class = icon_class
@property
def name(self):
return self.label.replace(' ', '_')
class PluginMenuItem:
"""

View File

@@ -1,9 +1,11 @@
from importlib import import_module
from django.apps import apps
from django.conf import settings
from django.conf.urls import include
from django.contrib.admin.views.decorators import staff_member_required
from django.urls import path
from django.utils.module_loading import import_string
from django.utils.module_loading import import_string, module_has_submodule
from . import views
@@ -19,24 +21,21 @@ plugin_admin_patterns = [
# Register base/API URL patterns for each plugin
for plugin_path in settings.PLUGINS:
plugin = import_module(plugin_path)
plugin_name = plugin_path.split('.')[-1]
app = apps.get_app_config(plugin_name)
base_url = getattr(app, 'base_url') or app.label
# Check if the plugin specifies any base URLs
try:
if module_has_submodule(plugin, 'urls'):
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
plugin_patterns.append(
path(f"{base_url}/", include((urlpatterns, app.label)))
)
except ImportError:
pass
# Check if the plugin specifies any API URLs
try:
if module_has_submodule(plugin, 'api.urls'):
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
plugin_api_patterns.append(
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
)
except ImportError:
pass

View File

@@ -1,8 +1,8 @@
import importlib
import inspect
import logging
import pkgutil
import traceback
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
@@ -11,7 +11,6 @@ from django_rq import job
from .choices import JobResultStatusChoices, LogLevelChoices
from .models import JobResult
logger = logging.getLogger(__name__)
@@ -85,10 +84,24 @@ def run_report(job_result, *args, **kwargs):
try:
job_result.start()
report.run(job_result)
except Exception as e:
except Exception:
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
job_result.save()
logging.error(f"Error during execution of report {job_result.name}")
finally:
# Schedule the next job if an interval has been set
start_time = job_result.scheduled or job_result.started
if start_time and job_result.interval:
new_scheduled_time = start_time + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_report,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
job_timeout=report.job_timeout,
schedule_at=new_scheduled_time,
interval=job_result.interval
)
class Report(object):

View File

@@ -4,8 +4,9 @@ import logging
import os
import pkgutil
import sys
import traceback
import threading
import traceback
from datetime import timedelta
import yaml
from django import forms
@@ -16,10 +17,11 @@ from django.utils.functional import classproperty
from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from extras.signals import clear_webhooks
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction
from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .context_managers import change_logging
from .forms import ScriptForm
@@ -468,6 +470,14 @@ def run_script(data, request, commit=True, *args, **kwargs):
except AbortTransaction:
script.log_info("Database changes have been reverted automatically.")
clear_webhooks.send(request)
except AbortScript as e:
script.log_failure(
f"Script aborted with error: {e}"
)
script.log_info("Database changes have been reverted due to error.")
logger.error(f"Script aborted with error: {e}")
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
clear_webhooks.send(request)
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
@@ -491,6 +501,22 @@ def run_script(data, request, commit=True, *args, **kwargs):
else:
_run_script()
# Schedule the next job if an interval has been set
if job_result.interval:
new_scheduled_time = job_result.scheduled + timedelta(minutes=job_result.interval)
JobResult.enqueue_job(
run_script,
name=job_result.name,
obj_type=job_result.obj_type,
user=job_result.user,
schedule_at=new_scheduled_time,
interval=job_result.interval,
job_timeout=script.job_timeout,
data=data,
request=request,
commit=commit
)
def get_scripts(use_names=False):
"""
@@ -498,27 +524,39 @@ def get_scripts(use_names=False):
defined name in place of the actual module name.
"""
scripts = {}
# Iterate through all modules within the scripts path. These are the user-created files in which reports are
# Get all modules within the scripts path. These are the user-created files in which scripts are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
# Use a lock as removing and loading modules is not thread safe
with lock:
# Remove cached module to ensure consistency with filesystem
if module_name in sys.modules:
modules = list(pkgutil.iter_modules([settings.SCRIPTS_ROOT]))
modules_bases = set([name.split(".")[0] for _, name, _ in modules])
# Deleting from sys.modules needs to done behind a lock to prevent race conditions where a module is
# removed from sys.modules while another thread is importing
with lock:
for module_name in list(sys.modules.keys()):
# Everything sharing a base module path with a module in the script folder is removed.
# We also remove all modules with a base module called "scripts". This allows modifying imported
# non-script modules without having to reload the RQ worker.
module_base = module_name.split(".")[0]
if module_base == "scripts" or module_base in modules_bases:
del sys.modules[module_name]
module = importer.find_module(module_name).load_module(module_name)
for importer, module_name, _ in modules:
module = importer.find_module(module_name).load_module(module_name)
if use_names and hasattr(module, 'name'):
module_name = module.name
module_scripts = {}
script_order = getattr(module, "script_order", ())
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]:
# 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

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