Compare commits

..

190 Commits

Author SHA1 Message Date
Jeremy Stretch
82c959570d Release v3.6-beta1 2023-08-02 13:30:08 -04:00
Jeremy Stretch
354dc4398a Update changelog 2023-08-02 11:18:52 -04:00
Jeremy Stretch
a698a93938 Closes #13350: Remove unused DeviceImportTable class 2023-08-02 11:18:06 -04:00
Jeremy Stretch
04c5e62d2b #8248: Permit users to manage their own bookmarks by default 2023-08-02 11:13:09 -04:00
Jeremy Stretch
aa747c3954 #12988: Correct URL path for CustomFieldChoiceSet API endpoint 2023-08-02 11:05:03 -04:00
Jeremy Stretch
1937c1fad6 #12175: Misc cleanup 2023-08-02 11:04:28 -04:00
Jeremy Stretch
bf20611668 #6391: Add device_role to DeviceWithConfigContextSerializer 2023-08-02 10:16:51 -04:00
Jeremy Stretch
8f271151a7 Closes #11519: Add a SQL index for IPAddress host value 2023-08-02 09:56:56 -04:00
Abhimanyu Saharan
0bb86f1e7d Replaces device_role with role on device model (#13342)
* replaces device_role with role on device model #6391

* fixes lint issue #6391

* revert the database user

* revert test_runner comment

* changes as per review

* Update references to device_role column in UserConfigs

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-02 09:55:52 -04:00
Jeremy Stretch
79030ecab2 #12589: Move username validation from form to NetBoxUser 2023-08-01 15:42:47 -04:00
Jeremy Stretch
ccb7568462 #8684: Drop support for 'obj' context var when rendering custom links (v3.5) 2023-08-01 15:33:25 -04:00
Jeremy Stretch
6208e0f7f6 #12591: Add extras.ConfigRevision to EXEMPT_EXCLUDE_MODELS 2023-08-01 14:56:59 -04:00
Jeremy Stretch
e64289e791 #12589: Remove obsolete admin resources 2023-08-01 14:35:28 -04:00
Jeremy Stretch
699b4dfade Update feature introduction flags 2023-08-01 14:25:25 -04:00
Jeremy Stretch
a89cec72a1 Update changelog 2023-08-01 14:13:48 -04:00
Abhimanyu Saharan
1cc78be6ca Adds custom field on webhook model (#13336)
* adds custom field on webhook model #11936

* adds tags on webhook model #11936

* Remove extraneous import; revert change to NetBoxModelForm (no longer needed)

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-01 14:05:47 -04:00
Jeremy Stretch
7b998cfeb4 #11732: Exclude _init_time from import form fields list 2023-08-01 11:53:35 -04:00
Abhimanyu Saharan
cbf4b43b35 Adds tags on contact assignment (#13328)
* adds tags on contact assignments #12882

* updated migration

* added tags on import form

* adds TagsMixin on ContactAssignmentType #12882

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-08-01 11:52:14 -04:00
Jeremy Stretch
c1ca8d5d8d Closes #12906: Make boto3 & dulwich libraries optional (#13324)
* Initial work on #12906

* Catch import errors during backend init

* Tweak error message

* Update requirements & add note to docs
2023-08-01 11:13:35 -04:00
Jeremy Stretch
43e6308d90 Closes #11732: Protect against errant overwriting of data via web UI forms 2023-08-01 09:06:51 -04:00
Arthur Hanson
e625a5667c Closes #13279: Wrap choice labels with gettext()
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-31 17:31:07 -04:00
Arthur Hanson
e284cd7e54 Closes #13150: Wrap table column headers with gettext()
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-31 14:35:28 -04:00
Jeremy Stretch
34a960505d Remove obsolete AdminGroup and AdminUser models (#12589) 2023-07-31 14:28:50 -04:00
Arthur Hanson
b7a9649269 Closes #13149: Wrap form field labels with gettext_lazy()
Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-31 12:52:38 -04:00
Arthur Hanson
83bebc1bd2 Closes #13132: Wrap verbose_name and other model text with gettext_lazy() (i18n)
---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-31 11:28:07 -04:00
Jeremy Stretch
80376abedf Closes #13309: Introduce the account app (#13310)
* Introduce 'accounts' app for user-specific views & resources
* Move UserTokenTable to account app
* Move login & logout views to account app
2023-07-31 09:22:04 -04:00
Jeremy Stretch
9c6c3d3dd4 Update changelog 2023-07-31 08:35:28 -04:00
Jeremy Stretch
ab0442bd5c Fix typo 2023-07-31 08:24:03 -04:00
Abhimanyu Saharan
36f95f7842 Adds tenant on power feed (#13300)
* adds tenant on power feed

* cleanup

* adds power feed count on tenant object view

* Misc cleanup; add filterset tests

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-31 08:20:48 -04:00
Jeremy Stretch
07f68ae579 Closes #13038: Establish DEFAULT_PERMISSIONS config parameter (#13308)
* Introduce the DEFAULT_PERMISSIONS config parameter

* Establish default permissions for user token management
2023-07-30 15:04:58 -04:00
Jeremy Stretch
ca634be7ad Closes #13311: Always use get_permission_for_model() to resolve permission names 2023-07-30 14:32:02 -04:00
Jeremy Stretch
2a0d76d564 Merge branch 'develop' into feature 2023-07-30 13:36:51 -04:00
Jeremy Stretch
0b10131564 Satisfy PEP8 E721 linter complaints 2023-07-30 13:34:08 -04:00
Arthur Hanson
7c17d2e932 Closes #13102: Establish initial translation support in templates
---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-28 16:30:25 -04:00
Jeremy Stretch
cf1b1a83eb Closes #12194: Add pre-defined custom field choices (#13219)
* Initial work on custom field choice sets

* Rename choices to extra_choices (prep for #12194)

* Remove CustomField.choices

* Add & update tests

* Clean up table columns

* Add order_alphanetically boolean for choice sets

* Introduce ArrayColumn for choice lists

* Show dependent custom fields on choice set view

* Update custom fields documentation

* Introduce ArrayWidget for more convenient editing of choices

* Incorporate PR feedback

* Misc cleanup

* Initial work on predefined choices for custom fields

* Misc cleanup

* Add IATA airport codes

* #13241: Add support for custom field choice labels

* Restore ArrayColumn

* Misc cleanup

* Change extra_choices back to a nested ArrayField to preserve choice ordering

* Hack to bypass GraphQL API test utility absent support for nested ArrayFields
2023-07-28 11:24:21 -04:00
Jeremy Stretch
9d3bb585a2 Update version 2023-07-28 10:36:44 -04:00
Jeremy Stretch
d52c18ce38 Merge branch 'develop' into feature 2023-07-28 10:36:09 -04:00
Jeremy Stretch
006c353d46 PRVB 2023-07-28 10:31:54 -04:00
Jeremy Stretch
6c53ca8909 Merge pull request #13294 from netbox-community/develop
Release v3.5.7
2023-07-28 10:29:46 -04:00
Jeremy Stretch
4f984c0831 Release v3.5.7 2023-07-28 10:11:16 -04:00
Jeremy Stretch
d9dc6cec3a Changelog for #11803, #13009, #13234, #13285 2023-07-28 10:02:42 -04:00
Jeremy Stretch
90146941b5 Fixes #13285: Cast default u_height value to a decimal for validation 2023-07-28 09:49:09 -04:00
Bruno Blanes
9d0457fe1a Add Brazilian power outlet standard to choices.py (#13012)
* Add Brazilian power outlet standard to choices.py

* Eliminate possible name conflict

* Rename group and add IEC 60906-1 plug type

* Update choices.py

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

* Remove unsupported tags field added by NetBoxModelImportForm

---------

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

* fixing missing comma

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

* Update netbox/dcim/choices.py

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

---------

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

* changes as per review
2023-07-27 15:59:41 -04:00
kkthxbye-code
5a3d46ac8d Remove vlan_group from nullable fields in InterfaceBulkEditForm 2023-07-27 15:58:16 -04:00
Fabian Geisberger
d075e7a66a Fixes #13237 - Allow unauthenticated api access to content-types. 2023-07-27 15:47:34 -04:00
kkthxbye-code
8b8adfbbbb Use class_name instead of name to get script results 2023-07-27 15:32:29 -04:00
Jeremy Stretch
0c2e3ff898 Merge pull request #13277 from netbox-community/13272-fix-graphql-test
13272 fix graphql test
2023-07-27 13:09:33 -04:00
Arthur
83c092f685 13272 fix graphql tests 2023-07-27 14:25:49 +07:00
Abhimanyu Saharan
0f9fe96192 Adds rf_role to interface template (#13199)
* adds rf_role to interface template #13170

* fixed migration file conflict

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-26 09:13:24 -04:00
Jeremy Stretch
1bcfcad9db Update changelog 2023-07-25 16:48:41 -04:00
Jeremy Stretch
5b5444f414 Closes #13269: Cache component template counts on device types 2023-07-25 16:38:05 -04:00
Jeremy Stretch
daa8f71bb6 Closes #10197: Add a cached counter field for virtual chassis members 2023-07-25 15:50:12 -04:00
Jeremy Stretch
9b6e32896d Clean up users & account URLs 2023-07-25 15:48:40 -04:00
Jamie (Bear) Murphy
154b8236a2 Oob ip (devices) (#13013)
* initial oob_ip support for devices

* add primary ip and oob ip checkmark to ip address view

* add oob ip to device view and device edit view

* pep8

* make is_oob_ip and is_primary_ip generic for other models

* refactor oob_ip

* fix oob ip signal

* string capitalisation

* Misc cleanup

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-25 14:40:40 -04:00
Arthur Hanson
7600d7b344 Closes #13228: Move token management views to primary UI 2023-07-25 13:43:40 -04:00
Arthur Hanson
149a496011 6347 Cache the number of each component type assigned to devices/VMs (#12632)
---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-25 09:39:05 -04:00
Arthur Hanson
a4acb50edd 12589 move user and group admin from admin (#12877)
Move admin views for users, groups, and object permissions from the admin site to the NetBox frontend

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-07-20 16:22:08 -04:00
Jeremy Stretch
96ea0ac9c7 Closes #12988: Introduce custom field choice sets (#13195)
* Initial work on custom field choice sets

* Rename choices to extra_choices (prep for #12194)

* Remove CustomField.choices

* Add & update tests

* Clean up table columns

* Add order_alphanetically boolean for choice sets

* Introduce ArrayColumn for choice lists

* Show dependent custom fields on choice set view

* Update custom fields documentation

* Introduce ArrayWidget for more convenient editing of choices

* Incorporate PR feedback

* Misc cleanup
2023-07-19 10:26:24 -04:00
Jeremy Stretch
837be4d45f Merge branch 'develop' into feature 2023-07-11 10:09:26 -04:00
Jeremy Stretch
0f0cf683c4 PRVB 2023-07-10 16:55:17 -04:00
Jeremy Stretch
ec0dbe33d3 Merge pull request #13142 from netbox-community/develop
Release v3.5.6
2023-07-10 16:53:46 -04:00
Jeremy Stretch
1c30a44b4e Release v3.5.6 2023-07-10 16:35:53 -04:00
Jeremy Stretch
252cc37f97 Changelog for #13061, #13096, #13105, #13116 2023-07-10 14:39:40 -04:00
Jeremy Stretch
f6fcf776a4 Fixes #13061: Fix display of last result for scripts & reports with a custom name defined 2023-07-10 14:13:45 -04:00
Jeremy Stretch
73348ee435 Fixes #13105: Avoid exception when attempting to allocate next available IP address from prefix marked as utilized 2023-07-10 13:53:31 -04:00
Abhimanyu Saharan
cab7b76220 Fixes form rendering when scheduling_enabled is disabled (#13123)
* fixes form rendering when scheduling_enabled is disabled #13096

* Remove requires_input property from BaseScript; render form consistently

---------

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

* Update vlans.py

* Update vlans.py

* Update vlangroup.html

* Update vlans.py

* Update vlans.py

* Update serializers.py

* adds db annotation to calculate utilization

* optimize queries

* merge fix

* adds round function for utilization to limit decimal

* fixed object view annotation

* consolidated queryset for utilization

* lint fixes

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

---------

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

* adds newline

* Omit hash mark from target string

* Clean up HTML element IDs

---------

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

* fix return proper parent object field

* improve code and wokflow

---------

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

* Employ canonical model discovery mechanism

* Move filtering logic to valid_models() queryset method

* fixed import to avoid content type does not exist

* Cleanup

---------

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

* remove the extra filter #12842
2023-06-29 16:02:39 -04:00
Daniel Sheppard
f69d99ea67 Fixes #12760 - Adds Vary header to cause cache to be keyed based on URL and the HX-Request header (or lack thereof) 2023-06-29 15:53:57 -04:00
Arthur
3754e00ee0 12809 document not to use underscores in model names 2023-06-29 15:49:47 -04:00
pobradovic08
dd6d9bf6e3 List multiple devices in Terminations (#13030) 2023-06-29 15:48:00 -04:00
Abhimanyu Saharan
183c7deb81 adds contact accessor fields on assignment table #12955 2023-06-29 14:50:30 -04:00
Jeremy Stretch
0a60a3fd2a Add OneMind Services as a sponsor 2023-06-29 14:47:10 -04:00
Jeremy Stretch
6e222f8dce Closes #8248: User bookmarks (#13035)
* Initial work on #8248

* Add tests

* Fix tests

* Add feature query for bookmarks

* Add BookmarksWidget

* Correct generic relation name

* Add docs for bookmarks

* Remove inheritance from ChangeLoggedModel
2023-06-29 14:36:11 -04:00
Jeremy Stretch
b13f9d27d9 Fix Repography charts 2023-06-27 10:19:12 -04:00
Jeremy Stretch
6b01b1df40 Changelog for #12849, #12945, #12961, #12977, #12983, #13011 2023-06-27 08:39:51 -04:00
Harm Geerts
34d32374a8 Fix #12983 bulk edit of M2M fields when nothing is selected
Partially revert 41c92483a0 to restore
bulk edit with m2m fields. The m2m cleaned_data yields a empty queryset
when nothing is selected. By setting the m2m relation unless set null is
checked even when nothing is selected the m2m relation is always
cleared.

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

* Remove unused import
2023-06-26 14:21:26 -04:00
Jeremy Stretch
74d9fe1ea2 Fixes #12961: Set correct return_url for object contacts tabs 2023-06-26 12:50:54 -04:00
Jeremy Stretch
d131d9b310 Correct changelog for #12953 2023-06-23 15:31:18 -04:00
Jeremy Stretch
32fe9fe8ec Changelog for #12533, #12960, #12963, #12966, #12975, #12989 2023-06-23 15:26:48 -04:00
Jeremy Stretch
882f29192c Fixes #12975: Correct URL for VirtualDeviceContext API serializer 2023-06-23 15:23:40 -04:00
Abhimanyu Saharan
27e850a68d fix ip address assignment #12953 2023-06-23 14:39:16 -04:00
Abhimanyu Saharan
c83b2499f0 fix db maintenance mode exception #12966 2023-06-23 14:36:50 -04:00
Abhimanyu Saharan
79c8219202 fixes device interface serializer #12533 2023-06-23 14:34:08 -04:00
Abhimanyu Saharan
49af70a77d fixes choice field schema for response #12960 2023-06-23 14:21:51 -04:00
Abhimanyu Saharan
7f96c7fee7 fixes most schema warnings #12257 2023-06-23 14:19:40 -04:00
Abhimanyu Saharan
13315f36d4 fixes device type and module type tag import #12949 2023-06-23 14:17:28 -04:00
Jeremy Stretch
1056e513b1 Closes #11541: Support for limiting tag assignments by object type (#12982)
* Initial work on #11541

* Merge migrations

* Limit tags by object type during assignment

* Add tests for object type validation

* Fix form field parameters
2023-06-23 14:08:14 -04:00
Arthur Hanson
69b818ed33 12237 update to Django 4.2 / psycopg3 (#12916)
* 12237 upgrade django and psycopg

* 12237 add migration

* 12237 rename migration

* 12237 update requirements

* 12237 fix migration

* Update base requirements

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-06-23 10:38:08 -04:00
Arthur Hanson
148278a74a 12591 config params admin (#12904)
* 12591 initial commit

* 12591 detail view

* 12591 add/edit view

* 12591 edit button

* 12591 base views and forms

* 12591 form cleanup

* 12591 form cleanup

* 12591 form cleanup

* 12591 review changes

* 12591 move check for restrictedqueryset

* 12591 restore view

* 12591 restore page styling

* 12591 remove admin

* Remove edit view for ConfigRevision instances

* Order ConfigRevisions by creation time

* Correct permission name

* Use RestrictedQuerySet for ConfigRevision

* Fix redirect URL

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-06-22 14:04:24 -04:00
Jeremy Stretch
48b2ab3587 Closes #12964: Raise minimum PostgreSQL version from 11 to 12 2023-06-22 12:27:21 -04:00
Jeremy Stretch
9fa1411d74 Changelog for #9077, #11305, #12175, #12180, #12794 2023-06-22 10:55:12 -04:00
Arthur Hanson
eff4a3741c 12175 rack with starting unit > 1 (#12778)
* 12175 add rack starting unit

* 12175 rack starting unit to svg

* verify devices can still fit if change rack starting_unit

* 12175 fix migration

* 12175 fix typo and test

* 12175 fix test

* 12175 fix max height calc display

* Misc cleanup & fixes

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2023-06-22 09:09:01 -04:00
Arthur Hanson
518fd8cca6 12794 change User ref to get_user_model (#12905)
* 12794 change User ref to get_user_model

* 12794 call get_user_model once in tests

* 12794 call get_user_model once in tests

* 12794 use settings.AUTH_USER_MODEL for FK reference
2023-06-22 08:26:50 -04:00
Jeremy Stretch
bace24b68e 12180 available objects api (#12935)
* Introduce AvailableObjectsView and refactor 'available objects' API views

* Restore advisory PostgreSQL locks

* Move get_next_available_prefix()

* Apply OpenAPI decorators for get() and post()
2023-06-20 15:04:10 -04:00
Jeremy Stretch
e7edccd9ba Merge branch 'develop' into feature 2023-06-20 14:53:07 -04:00
Jeremy Stretch
70c2b358ad PRVB 2023-06-20 14:27:18 -04:00
Jeremy Stretch
9dab3a0d79 Merge pull request #12946 from netbox-community/develop
Release v3.5.4
2023-06-20 14:24:34 -04:00
Jeremy Stretch
54622b5f92 Release v3.5.4 2023-06-20 13:56:09 -04:00
Jeremy Stretch
cdce500d90 Changelog for #12474, #12828, #12845, #12865, #12885, #12914 2023-06-15 16:15:15 -04:00
Luke Anderson
e11991c7a4 Fix #12865 - Include Add Nav Buttons for Report and Script Objects (#12909) 2023-06-15 15:04:08 -04:00
Jeremy Stretch
6ef333ea68 Fixes #12885: Permit mounting of devices in U100 (#12901)
* Fixes #12885: Permit mounting of devices in U100

* Define a RACK_U_HEIGHT_MAX constant
2023-06-15 15:00:45 -04:00
Jeremy Stretch
7fc69f3945 Fixes #12914: Clear stored ordering from user config when cleared by request 2023-06-15 14:59:52 -04:00
Jeremy Stretch
8aeb31751a Fixes #12845: Fix pagination of related IP addresses table 2023-06-15 13:07:51 -04:00
Arthur
0b2162569f 12474 update cable terminations when moving location between sites 2023-06-15 08:53:06 -04:00
Abhimanyu Saharan
93175888f0 add color to ChangeActionChoices #12828 2023-06-15 08:48:36 -04:00
Arthur
e635f0defd Merge branch 'develop' into feature 2023-06-14 16:32:56 -07:00
Arthur
b4a3156046 9077 audit alters_data=True 2023-06-14 14:23:55 -04:00
Arthur Hanson
4f76dcd2ea 11305 Add GPS coordinates to device (#12782)
* 11305 add lat/long to devices

* 11305 update docs

* 11305 update tests
2023-06-14 14:18:50 -04:00
Jeremy Stretch
4d686e8162 Changelog for #12622, #12682, #12818, #12822, #12847 2023-06-14 13:54:40 -04:00
Dillon Henschen
0e873a01b8 Closes #12622: Fix assigning VLAN without site to Prefix (#12784)
* Issue #12622: Fix creating Prefix using VLAN without site

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

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

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

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

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

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

* simplified conditions

* handle nonetype error

* fixed request check

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

---------

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

* changes as per review

* Add filterset tests for device type & role filters

---------

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

* 7503 fix single instance

* 7503 atomic transaction

* 7503 fix return data for bulk operations

* 7503 add test

* Move sequential creation logic to a mixin

---------

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

* moved hide logic from template to python

* fix indentation

* Update logic for omit_hidden under get_custom_fields()

* Update docs

* Account for False values

---------

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

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

---------

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

* fixed indentation #12470

* Use -flush CSS class to reduce whitespace

---------

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

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

* Always use BooleanFilter for 'empty' lookups

* Restore Empty lookup logic
2023-05-25 15:20:08 -04:00
jeremystretch
bf1c191b2e Fixes #12694: Strip leading & trailing whitespace from custom link URL & text 2023-05-24 15:45:24 -04:00
jeremystretch
b31b086a4d Link to the plugin ideas board 2023-05-23 15:34:03 -04:00
jeremystretch
6160e03426 PRVB 2023-05-22 17:00:29 -04:00
jeremystretch
4208b79514 Closes #12320: Remove obsolete fields napalm_driver and napalm_args from Platform 2023-05-16 09:35:27 -04:00
jeremystretch
02db0bcc2e Closes #11766: Remove obsolete custom ChoiceField and MultipleChoiceField classes 2023-05-12 16:27:50 -04:00
615 changed files with 135605 additions and 6169 deletions

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,4 @@
<div align="center">
<strong>The :ballot_box_with_check: <a href="https://forms.gle/zUHrrPo7K34yKaqC9">2023 NetBox Community Survey</a> is now open!</strong>
<p>Please take a few minutes to tell us about your NetBox deployment.</p>
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
<p>The premiere source of truth powering network automation</p>
<img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" />
@@ -55,10 +52,10 @@ as the cornerstone for network automation in thousands of organizations.
## Project Stats
<div align="center">
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg" alt="Top contributors"></a>
<a href="https://github.com/netbox-community/netbox/commits"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_timeline.svg" alt="Timeline graph"></a>
<a href="https://github.com/netbox-community/netbox/issues"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_issues.svg" alt="Issues graph"></a>
<a href="https://github.com/netbox-community/netbox/pulls"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_prs.svg" alt="Pull requests graph"></a>
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://images.repography.com/29023055/netbox-community/netbox/recent-activity/whQtEr_TGD9PhW1BPlhlEQ5jnrgQ0KJpm-LlGtpoGO0/3Kx_iWUSBRJ5-AI4QwJEJWrUDEz3KrX2lvh8aYE0WXY_users.svg" alt="Top contributors"></a>
<br />Stats via <a href="https://repography.com">Repography</a>
</div>
@@ -69,10 +66,12 @@ as the cornerstone for network automation in thousands of organizations.
[![NetBox Labs](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/netbox_labs.png)](https://netboxlabs.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)
<br />
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![Sentry](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/sentry.png)](https://sentry.io)
<br />
[![Equinix Metal](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/equinix.png)](https://metal.equinix.com)
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
[![OneMind Services](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/onemind_services.png)](https://onemindservices.com)
</div>

View File

@@ -2,13 +2,9 @@
# https://github.com/mozilla/bleach/blob/main/CHANGES
bleach
# Python client for Amazon AWS API
# https://github.com/boto/boto3/blob/develop/CHANGELOG.rst
boto3
# The Python web framework on which NetBox is built
# https://docs.djangoproject.com/en/stable/releases/
Django<4.2
Django<5.0
# Django middleware which permits cross-domain API requests
# https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst
@@ -74,17 +70,14 @@ drf-spectacular
# https://github.com/tfranzel/drf-spectacular-sidecar
drf-spectacular-sidecar
# Git client for file sync
# https://github.com/jelmer/dulwich/releases
dulwich
# RSS feed parser
# https://github.com/kurtmckee/feedparser/blob/develop/CHANGELOG.rst
feedparser
# Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django/releases
graphene_django
# Pinned to v3.0.0 for GraphiQL UI issue (see #12762)
graphene_django==3.0.0
# WSGI HTTP server
# https://docs.gunicorn.org/en/latest/news.html
@@ -120,8 +113,8 @@ netaddr
Pillow
# PostgreSQL database adapter for Python
# https://www.psycopg.org/docs/news.html
psycopg2-binary
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
psycopg[binary,pool]
# YAML rendering library
# https://github.com/yaml/pyyaml/blob/master/CHANGES

View File

@@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
### Default Permissions
#### Example Constraint Definitions
!!! info "This feature was introduced in NetBox v3.6."
While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported.
### Example Constraint Definitions
| Constraints | Description |
| ----------- | ----------- |

View File

@@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*']
## DATABASE
NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* `NAME` - Database name
* `USER` - PostgreSQL username

View File

@@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = (
---
## DEFAULT_PERMISSIONS
!!! info "This parameter was introduced in NetBox v3.6."
Default:
```python
{
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
}
```
This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose.
For example, to allow all users to create a device role beginning with the word "temp," you could configure the following:
```python
DEFAULT_PERMISSIONS = {
'dcim.add_devicerole': (
{'name__startswith': 'temp'},
)
}
```
!!! warning
Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration.
---
## EXEMPT_VIEW_PERMISSIONS
Default: Empty list

View File

@@ -60,7 +60,7 @@ NetBox supports limited custom validation for custom field values. Following are
### Custom Selection Fields
Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible.
Each custom selection field must designate a [choice set](../models/extras/customfieldchoiceset.md) containing at least two choices. These are specified as a comma-separated list.
If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected.

View File

@@ -390,7 +390,7 @@ class NewBranchScript(Script):
name=f'{site.slug}-switch{i}',
site=site,
status=DeviceStatusChoices.STATUS_PLANNED,
device_role=switch_role
role=switch_role
)
switch.full_clean()
switch.save()

View File

@@ -8,6 +8,10 @@ The registry can be inspected by importing `registry` from `extras.registry`.
## Stores
### `counter_fields`
A dictionary mapping of models to foreign keys with which cached counter fields are associated.
### `data_backends`
A dictionary mapping data backend types to their respective classes. These are used to interact with [remote data sources](../models/core/datasource.md).

View File

@@ -1,7 +1,5 @@
# Configuration Rendering
!!! info "This feature was introduced in NetBox v3.5."
One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
```mermaid

View File

@@ -18,6 +18,12 @@ The `tag` filter can be specified multiple times to match only objects which hav
GET /api/dcim/devices/?tag=monitored&tag=deprecated
```
## Bookmarks
!!! info "This feature was introduced in NetBox v3.6."
Users can bookmark their most commonly visited objects for convenient access. Bookmarks are listed under a user's profile, and can be displayed with custom filtering and ordering on the user's personal dashboard.
## Custom Fields
While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs.

View File

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

View File

@@ -1,7 +1,5 @@
# Synchronized Data
!!! info "This feature was introduced in NetBox v3.5."
Several models in NetBox support the automatic synchronization of local data from a designated remote source. For example, [configuration templates](./configuration-rendering.md) defined in NetBox can source their content from text files stored in a remote git repository. This accomplished using the core [data source](../models/core/datasource.md) and [data file](../models/core/datafile.md) models.
To enable remote data synchronization, the NetBox administrator first designates one or more remote data sources. NetBox currently supports the following source types:
@@ -12,6 +10,10 @@ To enable remote data synchronization, the NetBox administrator first designates
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
!!! info
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
Each type of remote source has its own configuration parameters. For instance, a git source will ask the user to specify a branch and authentication credentials. Once the source has been created, a synchronization job is run to automatically replicate remote files in the local database.
The following NetBox models can be associated with replicated data files:

View File

@@ -2,8 +2,8 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning "PostgreSQL 11 or later required"
NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported.
!!! warning "PostgreSQL 12 or later required"
NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported.
## Installation
@@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da
sudo systemctl enable postgresql
```
Before continuing, verify that you have installed PostgreSQL 11 or later:
Before continuing, verify that you have installed PostgreSQL 12 or later:
```no-highlight
psql -V
@@ -55,6 +55,9 @@ Within the shell, enter the following commands to create the database and user (
CREATE DATABASE netbox;
CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K';
ALTER DATABASE netbox OWNER TO netbox;
-- the next two commands are needed on PostgreSQL 15 and later
\connect netbox;
GRANT CREATE ON SCHEMA public TO netbox;
```
!!! danger "Use a strong password"

View File

@@ -211,6 +211,22 @@ By default, NetBox will use the local filesystem to store uploaded files. To use
sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt"
```
### Remote Data Sources
NetBox supports integration with several remote data sources via configurable backends. Each of these requires the installation of one or more additional libraries.
* Amazon S3: [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html)
* Git: [`dulwich`](https://www.dulwich.io/)
For example, to enable the Amazon S3 backend, add `boto3` to your local requirements file:
```no-highlight
sudo sh -c "echo 'boto3' >> /opt/netbox/local_requirements.txt"
```
!!! info
These packages were previously required in NetBox v3.5 but now are optional.
## Run the Upgrade Script
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:

View File

@@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox:
| Dependency | Minimum Version |
|------------|-----------------|
| Python | 3.8 |
| PostgreSQL | 11 |
| PostgreSQL | 12 |
| Redis | 4.0 |
Below is a simplified overview of the NetBox application stack for reference:

View File

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

View File

@@ -671,8 +671,6 @@ This header specifies the API version in use. This will always match the version
### `X-Request-ID`
!!! info "This feature was introduced in NetBox v3.5."
This header specifies the unique ID assigned to the received API request. It can be very handy for correlating a request with change records. For example, after creating several new objects, you can filter against the object changes API endpoint to retrieve the resulting change records:
```

View File

@@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
| Database | PostgreSQL 11+ |
| Database | PostgreSQL 12+ |
| Task queuing | Redis/django-rq |

View File

@@ -1,7 +1,5 @@
# Provider Accounts
!!! info "This model was introduced in NetBox v3.5."
This model can be used to represent individual accounts associated with a provider.
## Fields

View File

@@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev
!!! tip
Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy.
### Latitude & Longitude
GPS coordinates of the device for geolocation.
### Status
The device's operational status.
@@ -83,6 +87,10 @@ Each device may designate one primary IPv4 address and/or one primary IPv6 addre
!!! tip
NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter.
### Out-of-band (OOB) IP Address
Each device may designate its out-of-band IP address. Out-of-band IPs are typically used to access network infrastructure via a physically separate management network.
### Cluster
If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.)

View File

@@ -61,6 +61,10 @@ The canonical distance between the two vertical rails on a face. (This is typica
The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.

View File

@@ -0,0 +1,15 @@
# Bookmarks
!!! info "This feature was introduced in NetBox v3.6."
A user can bookmark individual objects for convenient access. Bookmarks are listed under a user's profile and can be displayed using a dashboard widget.
## Fields
### User
The user to whom the bookmark belongs.
### Object
The bookmarked object.

View File

@@ -68,19 +68,20 @@ Defines how filters are evaluated against custom field values.
Controls how and whether the custom field is displayed within the NetBox user interface.
| Option | Description |
|------------|--------------------------------------|
| Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI |
| Option | Description |
|-------------------|--------------------------------------------------|
| Read/write | Display and permit editing (default) |
| Read-only | Display field but disallow editing |
| Hidden | Do not display field in the UI |
| Hidden (if unset) | Display in the UI only when a value has been set |
### Default
The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices.
### Choices
### Choice Set
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
For selection and multi-select custom fields only, this is the [set of choices](./customfieldchoiceset.md) which are valid for the field.
### Cloneable

View File

@@ -0,0 +1,29 @@
# Custom Field Choice Sets
!!! info "This feature was introduced in NetBox v3.6."
Single- and multi-selection [custom fields](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
A choice set must define a base choice set and/or a set of arbitrary extra choices.
## Fields
### Name
The human-friendly name of the choice set.
### Base Choices
The set of pre-defined choices to include. Available sets are listed below. This is an optional setting.
* IATA airport codes
* ISO 3166 - Two-letter country codes
* UN/LOCODE - Five-character location identifiers
### Extra Choices
A set of custom choices that will be appended to the base choice set (if any).
### Order Alphabetically
If enabled, the choices list will be automatically ordered alphabetically. If disabled, choices will appear in the order in which they were defined.

View File

@@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This
### Color
The color to use when displaying the tag in the NetBox UI.
### Object Types
!!! info "This feature was introduced in NetBox v3.6."
The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines.
If no object types are specified, the tag will be assignable to any type of object.

View File

@@ -1,7 +1,5 @@
# ASN Ranges
!!! info "This model was introduced in NetBox v3.5."
Ranges can be defined to group [AS numbers](./asn.md) numerically and to facilitate their automatic provisioning. Each range must be assigned to a [RIR](./rir.md).
## Fields

View File

@@ -1,7 +1,5 @@
# Dashboard Widgets
!!! info "This feature was introduced in NetBox v3.5."
Each NetBox user can customize his or her personal dashboard by adding and removing widgets and by manipulating the size and position of each. Plugins can register their own dashboard widgets to complement those already available natively.
## The DashboardWidget Class

View File

@@ -165,19 +165,6 @@ In addition to the [form fields provided by Django](https://docs.djangoproject.c
options:
members: false
## Choice Fields
!!! warning "Obsolete Fields"
NetBox's custom `ChoiceField` and `MultipleChoiceField` classes are no longer necessary thanks to improvements made to the user interface. Django's native form fields can be used instead. These custom field classes will be removed in NetBox v3.6.
::: utilities.forms.fields.ChoiceField
options:
members: false
::: utilities.forms.fields.MultipleChoiceField
options:
members: false
## Dynamic Object Fields
::: utilities.forms.fields.DynamicModelChoiceField

View File

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

View File

@@ -1,5 +1,130 @@
# NetBox v3.5
## v3.5.8 (FUTURE)
---
## v3.5.7 (2023-07-28)
### Enhancements
* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view
* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source
* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types
* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
### Bug Fixes
* [#13051](https://github.com/netbox-community/netbox/issues/13051) - Fix Markdown support for table cell alignment
* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API
* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces
* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false
* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value
---
## v3.5.6 (2023-07-10)
### Bug Fixes
* [#13061](https://github.com/netbox-community/netbox/issues/13061) - Fix display of last result for scripts & reports with a custom name defined
* [#13096](https://github.com/netbox-community/netbox/issues/13096) - Hide scheduling fields for all scripts with scheduling disabled
* [#13105](https://github.com/netbox-community/netbox/issues/13105) - Fix exception when attempting to allocate next available IP address from prefix marked as utilized
* [#13116](https://github.com/netbox-community/netbox/issues/13116) - Catch ProgrammingError exception when starting NetBox without pre-populated content types
---
## v3.5.5 (2023-07-06)
### Enhancements
* [#11738](https://github.com/netbox-community/netbox/issues/11738) - Annotate VLAN group utilization
* [#12499](https://github.com/netbox-community/netbox/issues/12499) - Add "copy to clipboard" buttons in UI for IP addresses
* [#12945](https://github.com/netbox-community/netbox/issues/12945) - Add 100GE QSFP-DD interface type
* [#12955](https://github.com/netbox-community/netbox/issues/12955) - Include additional contact details on contact assignments table
* [#13065](https://github.com/netbox-community/netbox/issues/13065) - Associate contact assignments with their objects in the change log
### Bug Fixes
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Exclude stale content types when retrieving changelog records
* [#12533](https://github.com/netbox-community/netbox/issues/12533) - Fix REST API validation of null values for several interface attributes
* [#12579](https://github.com/netbox-community/netbox/issues/12579) - Fix exception when clicking "create and add another" to add a cable
* [#12617](https://github.com/netbox-community/netbox/issues/12617) - Populate prechange snapshot on parent object when assigning/removing primary IP address
* [#12760](https://github.com/netbox-community/netbox/issues/12760) - Avoid rendering partial HTMX responses when restoring browser tabs
* [#12842](https://github.com/netbox-community/netbox/issues/12842) - Improve handling of exceptions when loading reports
* [#12849](https://github.com/netbox-community/netbox/issues/12849) - Fix LDAP group permissions assignment for API clients
* [#12951](https://github.com/netbox-community/netbox/issues/12951) - Display consistent parent information for each termination under cable view
* [#12953](https://github.com/netbox-community/netbox/issues/12953) - Fix designation of primary IP addresses during interface assignment
* [#12960](https://github.com/netbox-community/netbox/issues/12960) - Fix OpenAPI schema for various choice fields
* [#12961](https://github.com/netbox-community/netbox/issues/12961) - Set correct return URL for object contacts tabs
* [#12966](https://github.com/netbox-community/netbox/issues/12966) - Avoid catching database exceptions when maintenance mode is disabled
* [#12975](https://github.com/netbox-community/netbox/issues/12975) - Correct URL for VirtualDeviceContext API serializer
* [#12977](https://github.com/netbox-community/netbox/issues/12977) - Fix URL parameters for object count dashboard widgets
* [#12983](https://github.com/netbox-community/netbox/issues/12983) - Avoid erroneously clearing many-to-many assignments during bulk edit
* [#12989](https://github.com/netbox-community/netbox/issues/12989) - Fix bulk import of tags for device & module types
* [#13011](https://github.com/netbox-community/netbox/issues/13011) - Do not escape commas when rendering custom links
* [#13047](https://github.com/netbox-community/netbox/issues/13047) - Correct ASN count under ASN ranges list
* [#13056](https://github.com/netbox-community/netbox/issues/13056) - Add `config_template` field to device API serializer
* [#13092](https://github.com/netbox-community/netbox/issues/13092) - Allow nullifying power port max & allocated draw values during bulk edit
* [#13100](https://github.com/netbox-community/netbox/issues/13100) - Fix ValueError exception when searching for virtual device context for non-numeric values
---
## v3.5.4 (2023-06-20)
### Enhancements
* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices
* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views
* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly
* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu
### Bug Fixes
* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site
* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site
* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint
* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces
* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job
* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs
* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values
* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table
* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list
* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100
* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request
---
## v3.5.3 (2023-06-02)
### Enhancements
* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules
* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components
* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration
* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures
* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset
* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets
### Bug Fixes
* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API
* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value
* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object
* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment
* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables
* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text
* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form
* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object
* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view
* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters
* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version
* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets
* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters
---
## v3.5.2 (2023-05-22)
### Enhancements

View File

@@ -0,0 +1,122 @@
# NetBox v3.6
## v3.6-beta1 (2023-08-02)
### Breaking Changes
* PostgreSQL 11 is no longer supported (dropped in Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later.
* The `device_role` field on the Device model has been renamed to `role`. The `device_role` field has been temporarily retained on the REST API serializer for devices for backward compatibility, but is read-only.
* The `choices` array field has been removed from the CustomField model. Any defined choices are automatically migrated to CustomFieldChoiceSets, accessible via the new `choice_set` field on the CustomField model.
* The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the Platform model.
### New Features
#### Relocated Admin Views ([#12589](https://github.com/netbox-community/netbox/issues/12589), [#12590](https://github.com/netbox-community/netbox/issues/12590), [#12591](https://github.com/netbox-community/netbox/issues/12591), [#13044](https://github.com/netbox-community/netbox/issues/13044))
Management views for the following object types, previously available only under the backend admin interface, have been relocated to the primary user interface:
* Users
* Groups
* Object permissions
* API tokens
* Configuration revisions
This migration provides a more consistent user experience and unlocks advanced functionality not feasible using Django's built-in views. The admin UI is scheduled for complete removal in NetBox v4.0.
#### Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
Administrators now have the option of configuring default permissions for _all_ users globally, regardless of explicit permission or group assignments granted in the database. This is accomplished by defining the `DEFAULT_PERMISSIONS` configuration parameter. By default, all users are granted permission to manage their own bookmarks and API tokens.
#### User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
Users can now bookmark their favorite objects in NetBox. Bookmarks are accessible under each user's personal bookmarks list, and can also be added as a dashboard widget.
#### Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
Selection and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields. Additionally, each choice within a set can now specify both a raw value and a human-friendly label (see [#13241](https://github.com/netbox-community/netbox/issues/13241)). Pre-existing custom field choices are migrated to choice sets automatically during the upgrade process.
#### Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
Users now have the option to employ one of several pre-defined sets of choices when creating a custom field. These include:
* IATA airport codes
* ISO 3166 country codes
* UN/LOCODE location identifiers
When defining a choice set, one of the above can be employed as the base set, with the option to define extra, custom choices as well.
#### Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
Tags may now be restricted to use with designated object types. Tags that have no specific object types assigned may be used with any object that supports tag assignment.
### Enhancements
* [#6347](https://github.com/netbox-community/netbox/issues/6347) - Cache the number of assigned components for devices and virtual machines
* [#8137](https://github.com/netbox-community/netbox/issues/8137) - Add a field for designating the out-of-band (OOB) IP address for devices
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#11519](https://github.com/netbox-community/netbox/issues/11519) - Add a SQL index for IP address host values to optimize queries
* [#11732](https://github.com/netbox-community/netbox/issues/11732) - Prevent inadvertent overwriting of object attributes by competing users
* [#11936](https://github.com/netbox-community/netbox/issues/11936) - Introduce support for tags and custom fields on webhooks
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
* [#12882](https://github.com/netbox-community/netbox/issues/12882) - Add tag support for contact assignments
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Other Changes
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.
* [#6391](https://github.com/netbox-community/netbox/issues/6391) - Rename the `device_role` field on Device to `role` for consistency with VirtualMachine
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view
* [#12237](https://github.com/netbox-community/netbox/issues/12237) - Upgrade Django to v4.2
* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model
* [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform
* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL 11
* [#13309](https://github.com/netbox-community/netbox/issues/13309) - User account-specific resources have been moved to a new `account` app for better organization
### REST API Changes
* Introduced the following endpoints:
* `/api/extras/bookmarks/`
* `/api/extras/custom-field-choice-sets/`
* Added the `/api/extras/custom-fields/{id}/choices/` endpoint for select and multi-select custom fields
* dcim.Device
* Renamed `device_role` to `device`. Added a read-only `device_role` field for limited backward compatibility.
* Added the `latitude` and `longitude` fields (for GPS coordinates)
* Added the `oob_ip` field for out-of-band IP address assignment
* dcim.DeviceType
* Added read-only counter fields for assigned component templates:
* `console_port_template_count`
* `console_server_port_template_count`
* `power_port_template_count`
* `power_outlet_template_count`
* `interface_template_count`
* `front_port_template_count`
* `rear_port_template_count`
* `device_bay_template_count`
* `module_bay_template_count`
* `inventory_item_template_count`
* dcim.InterfaceTemplate
* Added the `rf_role` field
* dcim.Platform
* Removed the `napalm_driver` and `napalm_args` fields
* dcim.PowerFeed
* Added the `tenant` field
* dcim.Rack
* Added the `starting_unit` field
* dcim.VirtualChassis
* Added the read-only `member_count` field
* extras.CustomField
* Removed the `choices` array field
* Added the `choice_set` foreign key field (to ChoiceSet)
* extras.Tag
* Added the `object_types` field for optional restriction to specific object types
* extras.Webhook
* Added `custom_fields` and `tags` support
* tenancy.ContactAssignment
* Added `tags` support
* virtualization.VirtualMachine
* Added the `oob_ip` field for out-of-band IP address assignment

View File

@@ -206,6 +206,7 @@ nav:
- VirtualChassis: 'models/dcim/virtualchassis.md'
- VirtualDeviceContext: 'models/dcim/virtualdevicecontext.md'
- Extras:
- Bookmark: 'models/extras/bookmark.md'
- Branch: 'models/extras/branch.md'
- ConfigContext: 'models/extras/configcontext.md'
- ConfigTemplate: 'models/extras/configtemplate.md'
@@ -273,6 +274,7 @@ nav:
- git Cheat Sheet: 'development/git-cheat-sheet.md'
- Release Notes:
- Summary: 'release-notes/index.md'
- Version 3.6: 'release-notes/version-3.6.md'
- Version 3.5: 'release-notes/version-3.5.md'
- Version 3.4: 'release-notes/version-3.4.md'
- Version 3.3: 'release-notes/version-3.3.md'

View File

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.1.10 on 2023-07-30 17:49
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0004_netboxgroup_netboxuser'),
]
operations = [
migrations.CreateModel(
name='UserToken',
fields=[
],
options={
'verbose_name': 'token',
'proxy': True,
'indexes': [],
'constraints': [],
},
bases=('users.token',),
),
]

View File

15
netbox/account/models.py Normal file
View File

@@ -0,0 +1,15 @@
from django.urls import reverse
from users.models import Token
class UserToken(Token):
"""
Proxy model for users to manage their own API tokens.
"""
class Meta:
proxy = True
verbose_name = 'token'
def get_absolute_url(self):
return reverse('account:usertoken', args=[self.pk])

55
netbox/account/tables.py Normal file
View File

@@ -0,0 +1,55 @@
from django.utils.translation import gettext as _
from account.models import UserToken
from netbox.tables import NetBoxTable, columns
__all__ = (
'UserTokenTable',
)
TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
{% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""
class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
"""
key = columns.TemplateColumn(
verbose_name=_('Key'),
template_code=TOKEN,
)
write_enabled = columns.BooleanColumn(
verbose_name=_('Write Enabled')
)
created = columns.DateColumn(
verbose_name=_('Created'),
)
expires = columns.DateColumn(
verbose_name=_('Expires'),
)
last_used = columns.DateTimeColumn(
verbose_name=_('Last Used'),
)
allowed_ips = columns.TemplateColumn(
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = UserToken
fields = (
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)

18
netbox/account/urls.py Normal file
View File

@@ -0,0 +1,18 @@
from django.urls import include, path
from utilities.urls import get_model_urls
from . import views
app_name = 'account'
urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
]

298
netbox/account/views.py Normal file
View File

@@ -0,0 +1,298 @@
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
from utilities.views import register_model_view
#
# Login/logout
#
class LoginView(View):
"""
Perform user authentication via the web UI.
"""
template_name = 'login.html'
@method_decorator(sensitive_post_parameters('password'))
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def gen_auth_data(self, name, url, params):
display_name, icon_name = get_auth_backend_display(name)
return {
'display_name': display_name,
'icon_name': icon_name,
'url': f'{url}?{urlencode(params)}',
}
def get_auth_backends(self, request):
auth_backends = []
saml_idps = get_saml_idps()
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
url = reverse('social:begin', args=[name])
params = {}
if next := request.GET.get('next'):
params['next'] = next
if name.lower() == 'saml' and saml_idps:
for idp in saml_idps:
params['idp'] = idp
data = self.gen_auth_data(name, url, params)
data['display_name'] = f'{data["display_name"]} ({idp})'
auth_backends.append(data)
else:
auth_backends.append(self.gen_auth_data(name, url, params))
return auth_backends
def get(self, request):
form = forms.LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def post(self, request):
logger = logging.getLogger('netbox.auth.login')
form = forms.LoginForm(request, data=request.POST)
if form.is_valid():
logger.debug("Login form validation was successful")
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
if get_config().MAINTENANCE_MODE:
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
# Authenticate user
auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated")
messages.success(request, f"Logged in as {request.user}.")
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def redirect_to_next(self, request, logger):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
else:
if redirect_url:
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
redirect_url = reverse('home')
return HttpResponseRedirect(redirect_url)
class LogoutView(View):
"""
Deauthenticate a web user.
"""
def get(self, request):
logger = logging.getLogger('netbox.auth.logout')
# Log out the user
username = request.user
auth_logout(request)
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
return response
#
# User profiles
#
class ProfileView(LoginRequiredMixin, View):
template_name = 'account/profile.html'
def get(self, request):
# Compile changelog table
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)
return render(request, self.template_name, {
'changelog_table': changelog_table,
'active_tab': 'profile',
})
class UserConfigView(LoginRequiredMixin, View):
template_name = 'account/preferences.html'
def get(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
def post(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid():
form.save()
messages.success(request, "Your preferences have been updated.")
return redirect('account:preferences')
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'account/password.html'
def get(self, request):
# LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('account:profile')
form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, {
'form': form,
'active_tab': 'password',
})
def post(self, request):
form = forms.PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.")
return redirect('account:profile')
return render(request, self.template_name, {
'form': form,
'active_tab': 'change_password',
})
#
# Bookmarks
#
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
table = BookmarkTable
template_name = 'account/bookmarks.html'
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def get_extra_context(self, request):
return {
'active_tab': 'bookmarks',
}
#
# User views for token management
#
class UserTokenListView(LoginRequiredMixin, View):
def get(self, request):
tokens = UserToken.objects.filter(user=request.user)
table = tables.UserTokenTable(tokens)
table.configure(request)
return render(request, 'account/token_list.html', {
'tokens': tokens,
'active_tab': 'api-tokens',
'table': table,
})
@register_model_view(UserToken)
class UserTokenView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
return render(request, 'account/token.html', {
'object': token,
'key': key,
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(generic.ObjectEditView):
queryset = UserToken.objects.all()
form = forms.UserTokenForm
default_return_url = 'account:usertoken_list'
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(UserToken, 'delete')
class UserTokenDeleteView(generic.ObjectDeleteView):
queryset = UserToken.objects.all()
default_return_url = 'account:usertoken_list'

View File

@@ -1,3 +1,5 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
@@ -16,12 +18,12 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONED = 'decommissioned'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_PROVISIONING, 'Provisioning', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_OFFLINE, 'Offline', 'red'),
(STATUS_DEPROVISIONING, 'Deprovisioning', 'yellow'),
(STATUS_DECOMMISSIONED, 'Decommissioned', 'gray'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_PROVISIONING, _('Provisioning'), 'blue'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_OFFLINE, _('Offline'), 'red'),
(STATUS_DEPROVISIONING, _('Deprovisioning'), 'yellow'),
(STATUS_DECOMMISSIONED, _('Decommissioned'), 'gray'),
]

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
from circuits.models import *
@@ -26,12 +26,11 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField(
label=_('Comments')
)
comments = CommentField()
model = Provider
fieldsets = (
@@ -44,16 +43,16 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField(
label=_('Comments')
)
comments = CommentField()
model = ProviderAccount
fieldsets = (
@@ -66,6 +65,7 @@ class ProviderAccountBulkEditForm(NetBoxModelBulkEditForm):
class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
required=False
)
@@ -75,12 +75,11 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
label=_('Service ID')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField(
label=_('Comments')
)
comments = CommentField()
model = ProviderNetwork
fieldsets = (
@@ -93,6 +92,7 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
@@ -106,14 +106,17 @@ class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm):
class CircuitBulkEditForm(NetBoxModelBulkEditForm):
type = DynamicModelChoiceField(
label=_('Type'),
queryset=CircuitType.objects.all(),
required=False
)
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
required=False
)
provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
required=False,
query_params={
@@ -121,19 +124,23 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
}
)
status = forms.ChoiceField(
label=_('Status'),
choices=add_blank_choice(CircuitStatusChoices),
required=False,
initial=''
)
tenant = DynamicModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False
)
install_date = forms.DateField(
label=_('Install date'),
required=False,
widget=DatePicker()
)
termination_date = forms.DateField(
label=_('Termination date'),
required=False,
widget=DatePicker()
)
@@ -145,18 +152,17 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
)
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
comments = CommentField(
label=_('Comments')
)
comments = CommentField()
model = Circuit
fieldsets = (
('Circuit', ('provider', 'type', 'status', 'description')),
('Service Parameters', ('provider_account', 'install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant',)),
(_('Circuit'), ('provider', 'type', 'status', 'description')),
(_('Service Parameters'), ('provider_account', 'install_date', 'termination_date', 'commit_rate')),
(_('Tenancy'), ('tenant',)),
)
nullable_fields = (
'tenant', 'commit_rate', 'description', 'comments',

View File

@@ -3,7 +3,7 @@ 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 django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms import BootstrapMixin
@@ -31,6 +31,7 @@ class ProviderImportForm(NetBoxModelImportForm):
class ProviderAccountImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
to_field_name='name',
help_text=_('Assigned provider')
@@ -45,6 +46,7 @@ class ProviderAccountImportForm(NetBoxModelImportForm):
class ProviderNetworkImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
to_field_name='name',
help_text=_('Assigned provider')
@@ -67,26 +69,31 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class CircuitImportForm(NetBoxModelImportForm):
provider = CSVModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
to_field_name='name',
help_text=_('Assigned provider')
)
provider_account = CSVModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
to_field_name='name',
help_text=_('Assigned provider account'),
required=False
)
type = CSVModelChoiceField(
label=_('Type'),
queryset=CircuitType.objects.all(),
to_field_name='name',
help_text=_('Type of circuit')
)
status = CSVChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
@@ -103,11 +110,13 @@ class CircuitImportForm(NetBoxModelImportForm):
class CircuitTerminationImportForm(BootstrapMixin, forms.ModelForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
required=False
)
provider_network = CSVModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
to_field_name='name',
required=False

View File

@@ -23,9 +23,9 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Provider
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('ASN', ('asn',)),
('Contacts', ('contact', 'contact_role', 'contact_group')),
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
(_('ASN'), ('asn',)),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -62,7 +62,7 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
model = ProviderAccount
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('provider_id', 'account')),
(_('Attributes'), ('provider_id', 'account')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
@@ -70,6 +70,7 @@ class ProviderAccountFilterForm(NetBoxModelFilterSetForm):
label=_('Provider')
)
account = forms.CharField(
label=_('Account'),
required=False
)
tag = TagFilterField(model)
@@ -79,7 +80,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
model = ProviderNetwork
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('provider_id', 'service_id')),
(_('Attributes'), ('provider_id', 'service_id')),
)
provider_id = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
@@ -87,6 +88,7 @@ class ProviderNetworkFilterForm(NetBoxModelFilterSetForm):
label=_('Provider')
)
service_id = forms.CharField(
label=_('Service id'),
max_length=100,
required=False
)
@@ -102,11 +104,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
model = Circuit
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Provider', ('provider_id', 'provider_account_id', 'provider_network_id')),
('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
(_('Provider'), ('provider_id', 'provider_account_id', 'provider_network_id')),
(_('Attributes'), ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')),
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
@@ -135,6 +137,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
label=_('Provider network')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=CircuitStatusChoices,
required=False
)
@@ -158,10 +161,12 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
label=_('Site')
)
install_date = forms.DateField(
label=_('Install date'),
required=False,
widget=DatePicker
)
termination_date = forms.DateField(
label=_('Termination date'),
required=False,
widget=DatePicker
)

View File

@@ -1,4 +1,4 @@
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
from circuits.models import *
@@ -29,7 +29,7 @@ class ProviderForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Provider', ('name', 'slug', 'asns', 'description', 'tags')),
(_('Provider'), ('name', 'slug', 'asns', 'description', 'tags')),
)
class Meta:
@@ -41,6 +41,7 @@ class ProviderForm(NetBoxModelForm):
class ProviderAccountForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all()
)
comments = CommentField()
@@ -54,12 +55,13 @@ class ProviderAccountForm(NetBoxModelForm):
class ProviderNetworkForm(NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all()
)
comments = CommentField()
fieldsets = (
('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')),
(_('Provider Network'), ('provider', 'name', 'service_id', 'description', 'tags')),
)
class Meta:
@@ -73,7 +75,7 @@ class CircuitTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Circuit Type', (
(_('Circuit Type'), (
'name', 'slug', 'description', 'tags',
)),
)
@@ -87,10 +89,12 @@ class CircuitTypeForm(NetBoxModelForm):
class CircuitForm(TenancyForm, NetBoxModelForm):
provider = DynamicModelChoiceField(
label=_('Provider'),
queryset=Provider.objects.all(),
selector=True
)
provider_account = DynamicModelChoiceField(
label=_('Provider account'),
queryset=ProviderAccount.objects.all(),
required=False,
query_params={
@@ -103,9 +107,9 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Circuit', ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')),
('Service Parameters', ('install_date', 'termination_date', 'commit_rate')),
('Tenancy', ('tenant_group', 'tenant')),
(_('Circuit'), ('provider', 'provider_account', 'cid', 'type', 'status', 'description', 'tags')),
(_('Service Parameters'), ('install_date', 'termination_date', 'commit_rate')),
(_('Tenancy'), ('tenant_group', 'tenant')),
)
class Meta:
@@ -125,15 +129,18 @@ class CircuitForm(TenancyForm, NetBoxModelForm):
class CircuitTerminationForm(NetBoxModelForm):
circuit = DynamicModelChoiceField(
label=_('Circuit'),
queryset=Circuit.objects.all(),
selector=True
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
selector=True
)
provider_network = DynamicModelChoiceField(
label=_('Provider network'),
queryset=ProviderNetwork.objects.all(),
required=False,
selector=True

View File

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from circuits.choices import *
from dcim.models import CabledObjectModel
@@ -34,8 +34,8 @@ class Circuit(PrimaryModel):
"""
cid = models.CharField(
max_length=100,
verbose_name='Circuit ID',
help_text=_("Unique circuit ID")
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
)
provider = models.ForeignKey(
to='circuits.Provider',
@@ -55,6 +55,7 @@ class Circuit(PrimaryModel):
related_name='circuits'
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=CircuitStatusChoices,
default=CircuitStatusChoices.STATUS_ACTIVE
@@ -69,17 +70,17 @@ class Circuit(PrimaryModel):
install_date = models.DateField(
blank=True,
null=True,
verbose_name='Installed'
verbose_name=_('installed')
)
termination_date = models.DateField(
blank=True,
null=True,
verbose_name='Terminates'
verbose_name=_('terminates')
)
commit_rate = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Commit rate (Kbps)',
verbose_name=_('commit rate (Kbps)'),
help_text=_("Committed rate")
)
@@ -162,7 +163,7 @@ class CircuitTermination(
term_side = models.CharField(
max_length=1,
choices=CircuitTerminationSideChoices,
verbose_name='Termination'
verbose_name=_('termination')
)
site = models.ForeignKey(
to='dcim.Site',
@@ -179,30 +180,31 @@ class CircuitTermination(
null=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)',
verbose_name=_('port speed (Kbps)'),
blank=True,
null=True,
help_text=_("Physical circuit speed")
help_text=_('Physical circuit speed')
)
upstream_speed = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Upstream speed (Kbps)',
verbose_name=_('upstream speed (Kbps)'),
help_text=_('Upstream speed, if different from port speed')
)
xconnect_id = models.CharField(
max_length=50,
blank=True,
verbose_name='Cross-connect ID',
help_text=_("ID of the local cross-connect")
verbose_name=_('cross-connect ID'),
help_text=_('ID of the local cross-connect')
)
pp_info = models.CharField(
max_length=100,
blank=True,
verbose_name='Patch panel/port(s)',
help_text=_("Patch panel ID and port number(s)")
verbose_name=_('patch panel/port(s)'),
help_text=_('Patch panel ID and port number(s)')
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)

View File

@@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.models import PrimaryModel
@@ -19,11 +19,13 @@ class Provider(PrimaryModel):
stores information pertinent to the user's relationship with the Provider.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
help_text=_("Full name of the provider")
help_text=_('Full name of the provider')
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
)
@@ -61,9 +63,10 @@ class ProviderAccount(PrimaryModel):
)
account = models.CharField(
max_length=100,
verbose_name='Account ID'
verbose_name=_('account ID')
)
name = models.CharField(
verbose_name=_('name'),
max_length=100,
blank=True
)
@@ -104,6 +107,7 @@ class ProviderNetwork(PrimaryModel):
unimportant to the user.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
provider = models.ForeignKey(
@@ -114,7 +118,7 @@ class ProviderNetwork(PrimaryModel):
service_id = models.CharField(
max_length=100,
blank=True,
verbose_name='Service ID'
verbose_name=_('service ID')
)
class Meta:

View File

@@ -1,3 +1,4 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from circuits.models import *
@@ -24,7 +25,8 @@ CIRCUITTERMINATION_LINK = """
class CircuitTypeTable(NetBoxTable):
name = tables.Column(
linkify=True
linkify=True,
verbose_name=_('Name'),
)
tags = columns.TagColumn(
url_name='circuits:circuittype_list'
@@ -32,7 +34,7 @@ class CircuitTypeTable(NetBoxTable):
circuit_count = columns.LinkedCountColumn(
viewname='circuits:circuit_list',
url_params={'type_id': 'pk'},
verbose_name='Circuits'
verbose_name=_('Circuits')
)
class Meta(NetBoxTable.Meta):
@@ -46,28 +48,31 @@ class CircuitTypeTable(NetBoxTable):
class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
cid = tables.Column(
linkify=True,
verbose_name='Circuit ID'
verbose_name=_('Circuit ID')
)
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True
)
provider_account = tables.Column(
linkify=True,
verbose_name='Account'
verbose_name=_('Account')
)
status = columns.ChoiceFieldColumn()
termination_a = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side A'
verbose_name=_('Side A')
)
termination_z = tables.TemplateColumn(
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
verbose_name=_('Side Z')
)
commit_rate = CommitRateColumn(
verbose_name='Commit Rate'
verbose_name=_('Commit Rate')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:circuit_list'
)

View File

@@ -1,4 +1,5 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from circuits.models import *
from django_tables2.utils import Accessor
from tenancy.tables import ContactsColumnMixin
@@ -14,35 +15,38 @@ __all__ = (
class ProviderTable(ContactsColumnMixin, NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
accounts = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='Accounts'
verbose_name=_('Accounts')
)
account_count = columns.LinkedCountColumn(
accessor=tables.A('accounts__count'),
viewname='circuits:provideraccount_list',
url_params={'account_id': 'pk'},
verbose_name='Account Count'
verbose_name=_('Account Count')
)
asns = columns.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs'
verbose_name=_('ASNs')
)
asn_count = columns.LinkedCountColumn(
accessor=tables.A('asns__count'),
viewname='ipam:asn_list',
url_params={'provider_id': 'pk'},
verbose_name='ASN Count'
verbose_name=_('ASN Count')
)
circuit_count = columns.LinkedCountColumn(
accessor=Accessor('count_circuits'),
viewname='circuits:circuit_list',
url_params={'provider_id': 'pk'},
verbose_name='Circuits'
verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:provider_list'
)
@@ -58,19 +62,25 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
account = tables.Column(
linkify=True
linkify=True,
verbose_name=_('Account'),
)
name = tables.Column(
verbose_name=_('Name'),
)
name = tables.Column()
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True
)
circuit_count = columns.LinkedCountColumn(
accessor=Accessor('count_circuits'),
viewname='circuits:circuit_list',
url_params={'provider_account_id': 'pk'},
verbose_name='Circuits'
verbose_name=_('Circuits')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='circuits:provideraccount_list'
)
@@ -86,12 +96,16 @@ class ProviderAccountTable(ContactsColumnMixin, NetBoxTable):
class ProviderNetworkTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
provider = tables.Column(
verbose_name=_('Provider'),
linkify=True
)
comments = columns.MarkdownColumn()
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='circuits:providernetwork_list'
)

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
@@ -63,12 +63,12 @@ class JobStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed'
CHOICES = (
(STATUS_PENDING, 'Pending', 'cyan'),
(STATUS_SCHEDULED, 'Scheduled', 'gray'),
(STATUS_RUNNING, 'Running', 'blue'),
(STATUS_COMPLETED, 'Completed', 'green'),
(STATUS_ERRORED, 'Errored', 'red'),
(STATUS_FAILED, 'Failed', 'red'),
(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

@@ -6,13 +6,9 @@ from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlparse
import boto3
from botocore.config import Config as Boto3Config
from django import forms
from django.conf import settings
from django.utils.translation import gettext as _
from dulwich import porcelain
from dulwich.config import ConfigDict
from netbox.registry import registry
from .choices import DataSourceTypeChoices
@@ -41,10 +37,22 @@ def register_backend(name):
class DataBackend:
parameters = {}
sensitive_parameters = []
# Prevent Django's template engine from calling the backend
# class when referenced via DataSource.backend_class
do_not_call_in_templates = True
def __init__(self, url, **kwargs):
self.url = url
self.params = kwargs
self.config = self.init_config()
def init_config(self):
"""
Hook to initialize the instance's configuration.
"""
return
@property
def url_scheme(self):
@@ -57,6 +65,7 @@ class DataBackend:
@register_backend(DataSourceTypeChoices.LOCAL)
class LocalBackend(DataBackend):
@contextmanager
def fetch(self):
logger.debug(f"Data source type is local; skipping fetch")
@@ -86,15 +95,30 @@ class GitBackend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'})
)
}
sensitive_parameters = ['password']
def init_config(self):
from dulwich.config import ConfigDict
# Initialize backend config
config = ConfigDict()
# Apply HTTP proxy (if configured)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
return config
@contextmanager
def fetch(self):
from dulwich import porcelain
local_path = tempfile.TemporaryDirectory()
config = ConfigDict()
clone_args = {
"branch": self.params.get('branch'),
"config": config,
"config": self.config,
"depth": 1,
"errstream": porcelain.NoneStream(),
"quiet": True,
@@ -108,10 +132,6 @@ class GitBackend(DataBackend):
}
)
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
config.set("http", "proxy", proxy)
logger.debug(f"Cloning git repo: {self.url}")
try:
porcelain.clone(self.url, local_path.name, **clone_args)
@@ -135,18 +155,24 @@ class S3Backend(DataBackend):
widget=forms.TextInput(attrs={'class': 'form-control'})
),
}
sensitive_parameters = ['aws_secret_access_key']
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'
@contextmanager
def fetch(self):
local_path = tempfile.TemporaryDirectory()
def init_config(self):
from botocore.config import Config as Boto3Config
# Build the S3 configuration
s3_config = Boto3Config(
# Initialize backend config
return Boto3Config(
proxies=settings.HTTP_PROXIES,
)
@contextmanager
def fetch(self):
import boto3
local_path = tempfile.TemporaryDirectory()
# Initialize the S3 resource and bucket
aws_access_key_id = self.params.get('aws_access_key_id')
aws_secret_access_key = self.params.get('aws_secret_access_key')
@@ -155,7 +181,7 @@ class S3Backend(DataBackend):
region_name=self._region_name,
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
config=s3_config
config=self.config
)
bucket = s3.Bucket(self._bucket_name)

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from core.choices import DataSourceTypeChoices
from core.models import *
@@ -15,6 +15,7 @@ __all__ = (
class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(DataSourceTypeChoices),
required=False,
initial=''
@@ -25,16 +26,17 @@ class DataSourceBulkEditForm(NetBoxModelBulkEditForm):
label=_('Enforce unique space')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField(
label=_('Comments')
)
comments = CommentField()
parameters = forms.JSONField(
label=_('Parameters'),
required=False
)
ignore_rules = forms.CharField(
label=_('Ignore rules'),
required=False,
widget=forms.Textarea()
)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from core.choices import *
from core.models import *
@@ -23,17 +23,20 @@ class DataSourceFilterForm(NetBoxModelFilterSetForm):
model = DataSource
fieldsets = (
(None, ('q', 'filter_id')),
('Data Source', ('type', 'status')),
(_('Data Source'), ('type', 'status')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=DataSourceTypeChoices,
required=False
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=DataSourceStatusChoices,
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
@@ -45,7 +48,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
model = DataFile
fieldsets = (
(None, ('q', 'filter_id')),
('File', ('source_id',)),
(_('File'), ('source_id',)),
)
source_id = DynamicModelMultipleChoiceField(
queryset=DataSource.objects.all(),
@@ -57,8 +60,8 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
class JobFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('Attributes', ('object_type', 'status')),
('Creation', (
(_('Attributes'), ('object_type', 'status')),
(_('Creation'), (
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
'started__after', 'completed__before', 'completed__after', 'user',
)),
@@ -69,43 +72,52 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
required=False,
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=JobStatusChoices,
required=False
)
created__after = forms.DateTimeField(
label=_('Created after'),
required=False,
widget=DateTimePicker()
)
created__before = forms.DateTimeField(
label=_('Created before'),
required=False,
widget=DateTimePicker()
)
scheduled__after = forms.DateTimeField(
label=_('Scheduled after'),
required=False,
widget=DateTimePicker()
)
scheduled__before = forms.DateTimeField(
label=_('Scheduled before'),
required=False,
widget=DateTimePicker()
)
started__after = forms.DateTimeField(
label=_('Started after'),
required=False,
widget=DateTimePicker()
)
started__before = forms.DateTimeField(
label=_('Started before'),
required=False,
widget=DateTimePicker()
)
completed__after = forms.DateTimeField(
label=_('Completed after'),
required=False,
widget=DateTimePicker()
)
completed__before = forms.DateTimeField(
label=_('Completed before'),
required=False,
widget=DateTimePicker()
)
user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from core.models import DataFile, DataSource
from utilities.forms.fields import DynamicModelChoiceField

View File

@@ -1,6 +1,7 @@
import copy
from django import forms
from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin
from core.models import *
@@ -38,11 +39,11 @@ class DataSourceForm(NetBoxModelForm):
@property
def fieldsets(self):
fieldsets = [
('Source', ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
(_('Source'), ('name', 'type', 'source_url', 'enabled', 'description', 'tags', 'ignore_rules')),
]
if self.backend_fields:
fieldsets.append(
('Backend Parameters', self.backend_fields)
(_('Backend Parameters'), self.backend_fields)
)
return fieldsets
@@ -79,8 +80,8 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
)
fieldsets = (
('File Upload', ('upload_file',)),
('Data Source', ('data_source', 'data_file', 'auto_sync_enabled')),
(_('File Upload'), ('upload_file',)),
(_('Data Source'), ('data_source', 'data_file', 'auto_sync_enabled')),
)
class Meta:

View File

@@ -5,7 +5,7 @@ import sys
from django import get_version
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
@@ -60,7 +60,7 @@ class Command(BaseCommand):
# Additional objects to include
namespace['ContentType'] = ContentType
namespace['User'] = User
namespace['User'] = get_user_model()
# Load convenience commands
namespace.update({

View File

@@ -39,10 +39,12 @@ class DataSource(JobsMixin, PrimaryModel):
A remote source, such as a git repository, from which DataFiles are synchronized.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
)
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=DataSourceTypeChoices,
default=DataSourceTypeChoices.LOCAL
@@ -52,23 +54,28 @@ class DataSource(JobsMixin, PrimaryModel):
verbose_name=_('URL')
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=DataSourceStatusChoices,
default=DataSourceStatusChoices.NEW,
editable=False
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True
)
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
help_text=_("Patterns (one per line) matching files to ignore when syncing")
)
parameters = models.JSONField(
verbose_name=_('parameters'),
blank=True,
null=True
)
last_synced = models.DateTimeField(
verbose_name=_('last synced'),
blank=True,
null=True,
editable=False
@@ -97,6 +104,10 @@ class DataSource(JobsMixin, PrimaryModel):
def url_scheme(self):
return urlparse(self.source_url).scheme.lower()
@property
def backend_class(self):
return registry['data_backends'].get(self.type)
@property
def is_local(self):
return self.type == DataSourceTypeChoices.LOCAL
@@ -132,17 +143,15 @@ class DataSource(JobsMixin, PrimaryModel):
)
def get_backend(self):
backend_cls = registry['data_backends'].get(self.type)
backend_params = self.parameters or {}
return backend_cls(self.source_url, **backend_params)
return self.backend_class(self.source_url, **backend_params)
def sync(self):
"""
Create/update/delete child DataFiles as necessary to synchronize with the remote source.
"""
if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError(f"Cannot initiate sync; syncing already in progress.")
raise SyncError("Cannot initiate sync; syncing already in progress.")
# Emit the pre_sync signal
pre_sync.send(sender=self.__class__, instance=self)
@@ -151,7 +160,12 @@ class DataSource(JobsMixin, PrimaryModel):
DataSource.objects.filter(pk=self.pk).update(status=self.status)
# Replicate source data locally
backend = self.get_backend()
try:
backend = self.get_backend()
except ModuleNotFoundError as e:
raise SyncError(
f"There was an error initializing the backend. A dependency needs to be installed: {e}"
)
with backend.fetch() as local_path:
logger.debug(f'Syncing files from source root {local_path}')
@@ -200,6 +214,7 @@ class DataSource(JobsMixin, PrimaryModel):
# Emit the post_sync signal
post_sync.send(sender=self.__class__, instance=self)
sync.alters_data = True
def _walk(self, root):
"""
@@ -238,9 +253,11 @@ class DataFile(models.Model):
updated, or deleted only by calling DataSource.sync().
"""
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
last_updated = models.DateTimeField(
verbose_name=_('last updated'),
editable=False
)
source = models.ForeignKey(
@@ -250,20 +267,23 @@ class DataFile(models.Model):
editable=False
)
path = models.CharField(
verbose_name=_('path'),
max_length=1000,
editable=False,
help_text=_("File path relative to the data source's root")
)
size = models.PositiveIntegerField(
editable=False
editable=False,
verbose_name=_('size')
)
hash = models.CharField(
verbose_name=_('hash'),
max_length=64,
editable=False,
validators=[
RegexValidator(regex='^[0-9a-f]{64}$', message=_("Length must be 64 hexadecimal characters."))
],
help_text=_("SHA256 hash of the file data")
help_text=_('SHA256 hash of the file data')
)
data = models.BinaryField()
@@ -289,8 +309,10 @@ class DataFile(models.Model):
@property
def data_as_string(self):
if not self.data:
return None
try:
return self.data.tobytes().decode('utf-8')
return bytes(self.data, 'utf-8')
except UnicodeDecodeError:
return None

View File

@@ -23,20 +23,24 @@ class ManagedFile(SyncedDataMixin, models.Model):
to provide additional functionality.
"""
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
last_updated = models.DateTimeField(
verbose_name=_('last updated'),
editable=False,
blank=True,
null=True
)
file_root = models.CharField(
verbose_name=_('file root'),
max_length=1000,
choices=ManagedFileRootPathChoices
)
file_path = models.FilePathField(
verbose_name=_('file path'),
editable=False,
help_text=_("File path relative to the designated root path")
help_text=_('File path relative to the designated root path')
)
objects = RestrictedQuerySet.as_manager()

View File

@@ -1,7 +1,7 @@
import uuid
import django_rq
from django.contrib.auth.models import User
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MinValueValidator
@@ -43,48 +43,57 @@ class Job(models.Model):
for_concrete_model=False
)
name = models.CharField(
verbose_name=_('name'),
max_length=200
)
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
scheduled = models.DateTimeField(
verbose_name=_('scheduled'),
null=True,
blank=True
)
interval = models.PositiveIntegerField(
verbose_name=_('interval'),
blank=True,
null=True,
validators=(
MinValueValidator(1),
),
help_text=_("Recurrence interval (in minutes)")
help_text=_('Recurrence interval (in minutes)')
)
started = models.DateTimeField(
verbose_name=_('started'),
null=True,
blank=True
)
completed = models.DateTimeField(
verbose_name=_('completed'),
null=True,
blank=True
)
user = models.ForeignKey(
to=User,
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
status = models.CharField(
verbose_name=_('status'),
max_length=30,
choices=JobStatusChoices,
default=JobStatusChoices.STATUS_PENDING
)
data = models.JSONField(
verbose_name=_('data'),
null=True,
blank=True
)
job_id = models.UUIDField(
verbose_name=_('job ID'),
unique=True
)

View File

@@ -1,3 +1,4 @@
from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from core.models import *
@@ -11,11 +12,18 @@ __all__ = (
class DataSourceTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
type = columns.ChoiceFieldColumn()
status = columns.ChoiceFieldColumn()
enabled = columns.BooleanColumn()
type = columns.ChoiceFieldColumn(
verbose_name=_('Type'),
)
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
enabled = columns.BooleanColumn(
verbose_name=_('Enabled'),
)
tags = columns.TagColumn(
url_name='core:datasource_list'
)
@@ -34,12 +42,16 @@ class DataSourceTable(NetBoxTable):
class DataFileTable(NetBoxTable):
source = tables.Column(
verbose_name=_('Source'),
linkify=True
)
path = tables.Column(
verbose_name=_('Path'),
linkify=True
)
last_updated = columns.DateTimeColumn()
last_updated = columns.DateTimeColumn(
verbose_name=_('Last updated'),
)
actions = columns.ActionsColumn(
actions=('delete',)
)

View File

@@ -1,5 +1,5 @@
import django_tables2 as tables
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns
from ..models import Job
@@ -7,23 +7,38 @@ from ..models import Job
class JobTable(NetBoxTable):
id = tables.Column(
verbose_name=_('ID'),
linkify=True
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True
)
object_type = columns.ContentTypeColumn(
verbose_name=_('Type')
)
object = tables.Column(
verbose_name=_('Object'),
linkify=True
)
status = columns.ChoiceFieldColumn()
created = columns.DateTimeColumn()
scheduled = columns.DateTimeColumn()
interval = columns.DurationColumn()
started = columns.DateTimeColumn()
completed = columns.DateTimeColumn()
status = columns.ChoiceFieldColumn(
verbose_name=_('Status'),
)
created = columns.DateTimeColumn(
verbose_name=_('Created'),
)
scheduled = columns.DateTimeColumn(
verbose_name=_('Scheduled'),
)
interval = columns.DurationColumn(
verbose_name=_('Interval'),
)
started = columns.DateTimeColumn(
verbose_name=_('Started'),
)
completed = columns.DateTimeColumn(
verbose_name=_('Completed'),
)
actions = columns.ActionsColumn(
actions=('delete',)
)

View File

@@ -214,9 +214,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', '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',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', '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',
]
@@ -327,12 +327,28 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
# Counter fields
console_port_template_count = serializers.IntegerField(read_only=True)
console_server_port_template_count = serializers.IntegerField(read_only=True)
power_port_template_count = serializers.IntegerField(read_only=True)
power_outlet_template_count = serializers.IntegerField(read_only=True)
interface_template_count = serializers.IntegerField(read_only=True)
front_port_template_count = serializers.IntegerField(read_only=True)
rear_port_template_count = serializers.IntegerField(read_only=True)
device_bay_template_count = serializers.IntegerField(read_only=True)
module_bay_template_count = serializers.IntegerField(read_only=True)
inventory_item_template_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = [
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'id', 'url', 'display', 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height',
'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit', 'front_image', 'rear_image',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'console_port_template_count', 'console_server_port_template_count', 'power_port_template_count',
'power_outlet_template_count', 'interface_template_count', 'front_port_template_count',
'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
'inventory_item_template_count',
]
@@ -498,12 +514,18 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
allow_blank=True,
allow_null=True
)
rf_role = ChoiceField(
choices=WirelessRoleChoices,
required=False,
allow_blank=True,
allow_null=True
)
class Meta:
model = InterfaceTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
'description', 'bridge', 'poe_mode', 'poe_type', 'created', 'last_updated',
'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
]
@@ -635,15 +657,16 @@ class PlatformSerializer(NetBoxModelSerializer):
class Meta:
model = Platform
fields = [
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
class DeviceSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
role = NestedDeviceRoleSerializer()
device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
@@ -663,19 +686,35 @@ class DeviceSerializer(NetBoxModelSerializer):
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
oob_ip = NestedIPAddressSerializer(required=False, allow_null=True)
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
# Counter fields
console_port_count = serializers.IntegerField(read_only=True)
console_server_port_count = serializers.IntegerField(read_only=True)
power_port_count = serializers.IntegerField(read_only=True)
power_outlet_count = serializers.IntegerField(read_only=True)
interface_count = serializers.IntegerField(read_only=True)
front_port_count = serializers.IntegerField(read_only=True)
rear_port_count = serializers.IntegerField(read_only=True)
device_bay_count = serializers.IntegerField(read_only=True)
module_bay_count = serializers.IntegerField(read_only=True)
inventory_item_count = serializers.IntegerField(read_only=True)
class Meta:
model = Device
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', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(NestedDeviceSerializer)
@@ -689,16 +728,22 @@ class DeviceSerializer(NetBoxModelSerializer):
data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
return data
def get_device_role(self, obj):
return obj.role
class DeviceWithConfigContextSerializer(DeviceSerializer):
config_context = serializers.SerializerMethodField(read_only=True)
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',
'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow',
'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
'vc_priority', 'description', 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context',
'config_template', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
'device_bay_count', 'module_bay_count', 'inventory_item_count',
]
@extend_schema_field(serializers.JSONField(allow_null=True))
@@ -707,7 +752,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = NestedDeviceSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
primary_ip = NestedIPAddressSerializer(read_only=True, allow_null=True)
@@ -880,12 +925,12 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
parent = NestedInterfaceSerializer(required=False, allow_null=True)
bridge = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True)
duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True, allow_null=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True, allow_null=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True, allow_null=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True, allow_null=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True, allow_null=True)
rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True)
rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True)
poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True)
poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -907,9 +952,10 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
mac_address = serializers.CharField(
required=False,
default=None,
allow_blank=True,
allow_null=True
)
wwn = serializers.CharField(required=False, default=None)
wwn = serializers.CharField(required=False, default=None, allow_blank=True, allow_null=True)
class Meta:
model = Interface
@@ -993,7 +1039,8 @@ class ModuleBaySerializer(NetBoxModelSerializer):
class Meta:
model = ModuleBay
fields = [
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
'custom_fields',
'created', 'last_updated',
]
@@ -1136,13 +1183,15 @@ class CablePathSerializer(serializers.ModelSerializer):
class VirtualChassisSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
# Counter fields
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
'member_count', 'created', 'last_updated',
'created', 'last_updated', 'member_count',
]
@@ -1192,6 +1241,10 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
choices=PowerFeedPhaseChoices,
default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
)
tenant = NestedTenantSerializer(
required=False,
allow_null=True
)
class Meta:
model = PowerFeed
@@ -1199,5 +1252,5 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@@ -1,12 +1,12 @@
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, OpenApiParameter
from rest_framework.decorators import action
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.routers import APIRootView
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.viewsets import ViewSet
from circuits.models import Circuit
@@ -14,7 +14,6 @@ from dcim import filtersets
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from dcim.models import *
from dcim.svg import CableTraceSVG
from extras.api.nested_serializers import NestedConfigTemplateSerializer
from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin
from ipam.models import Prefix, VLAN
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import StripCountAnnotationsPaginator
from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin
from netbox.constants import NESTED_SERIALIZER_PREFIX
from utilities.api import get_serializer_for_model
from utilities.utils import count_related
@@ -362,7 +362,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet):
class DeviceRoleViewSet(NetBoxModelViewSet):
queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
device_count=count_related(Device, 'device_role'),
device_count=count_related(Device, 'role'),
virtualmachine_count=count_related(VirtualMachine, 'role')
)
serializer_class = serializers.DeviceRoleSerializer
@@ -386,9 +386,14 @@ class PlatformViewSet(NetBoxModelViewSet):
# Devices/modules
#
class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet):
class DeviceViewSet(
SequentialBulkCreatesMixin,
ConfigContextQuerySetMixin,
ConfigTemplateRenderMixin,
NetBoxModelViewSet
):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'device_type__manufacturer', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags',
)
filterset_class = filtersets.DeviceFilterSet
@@ -574,9 +579,7 @@ class CableTerminationViewSet(NetBoxModelViewSet):
#
class VirtualChassisViewSet(NetBoxModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=count_related(Device, 'virtual_chassis')
)
queryset = VirtualChassis.objects.prefetch_related('tags')
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filtersets.VirtualChassisFilterSet
brief_prefetch_fields = ['master']
@@ -641,7 +644,10 @@ class ConnectedDeviceViewSet(ViewSet):
def get_view_name(self):
return "Connected Device Locator"
@extend_schema(responses={200: OpenApiTypes.OBJECT})
@extend_schema(
parameters=[_device_param, _interface_param],
responses={200: serializers.DeviceSerializer}
)
def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name)

View File

@@ -9,7 +9,8 @@ class DCIMConfig(AppConfig):
def ready(self):
from . import signals, search
from .models import CableTermination
from .models import CableTermination, Device, DeviceType, VirtualChassis
from utilities.counters import connect_counters
# Register denormalized fields
denormalized.register(CableTermination, '_device', {
@@ -24,3 +25,6 @@ class DCIMConfig(AppConfig):
denormalized.register(CableTermination, '_location', {
'_site': 'site',
})
# Register counters
connect_counters(Device, DeviceType, VirtualChassis)

View File

@@ -1,3 +1,5 @@
from django.utils.translation import gettext_lazy as _
from utilities.choices import ChoiceSet
@@ -15,11 +17,11 @@ class SiteStatusChoices(ChoiceSet):
STATUS_RETIRED = 'retired'
CHOICES = [
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_STAGING, 'Staging', 'blue'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_RETIRED, 'Retired', 'red'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGING, _('Staging'), 'blue'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
(STATUS_RETIRED, _('Retired'), 'red'),
]
@@ -60,13 +62,13 @@ class RackTypeChoices(ChoiceSet):
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)'),
(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)')),
)
@@ -78,10 +80,10 @@ class RackWidthChoices(ChoiceSet):
WIDTH_23IN = 23
CHOICES = (
(WIDTH_10IN, '10 inches'),
(WIDTH_19IN, '19 inches'),
(WIDTH_21IN, '21 inches'),
(WIDTH_23IN, '23 inches'),
(WIDTH_10IN, _('10 inches')),
(WIDTH_19IN, _('19 inches')),
(WIDTH_21IN, _('21 inches')),
(WIDTH_23IN, _('23 inches')),
)
@@ -95,11 +97,11 @@ class RackStatusChoices(ChoiceSet):
STATUS_DEPRECATED = 'deprecated'
CHOICES = [
(STATUS_RESERVED, 'Reserved', 'yellow'),
(STATUS_AVAILABLE, 'Available', 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_ACTIVE, 'Active', 'blue'),
(STATUS_DEPRECATED, 'Deprecated', 'red'),
(STATUS_RESERVED, _('Reserved'), 'yellow'),
(STATUS_AVAILABLE, _('Available'), 'green'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_ACTIVE, _('Active'), 'blue'),
(STATUS_DEPRECATED, _('Deprecated'), 'red'),
]
@@ -109,8 +111,8 @@ class RackDimensionUnitChoices(ChoiceSet):
UNIT_INCH = 'in'
CHOICES = (
(UNIT_MILLIMETER, 'Millimeters'),
(UNIT_INCH, 'Inches'),
(UNIT_MILLIMETER, _('Millimeters')),
(UNIT_INCH, _('Inches')),
)
@@ -135,8 +137,8 @@ class SubdeviceRoleChoices(ChoiceSet):
ROLE_CHILD = 'child'
CHOICES = (
(ROLE_PARENT, 'Parent'),
(ROLE_CHILD, 'Child'),
(ROLE_PARENT, _('Parent')),
(ROLE_CHILD, _('Child')),
)
@@ -150,8 +152,8 @@ class DeviceFaceChoices(ChoiceSet):
FACE_REAR = 'rear'
CHOICES = (
(FACE_FRONT, 'Front'),
(FACE_REAR, 'Rear'),
(FACE_FRONT, _('Front')),
(FACE_REAR, _('Rear')),
)
@@ -167,13 +169,13 @@ class DeviceStatusChoices(ChoiceSet):
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_INVENTORY, 'Inventory', 'purple'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGED, _('Staged'), 'blue'),
(STATUS_FAILED, _('Failed'), 'red'),
(STATUS_INVENTORY, _('Inventory'), 'purple'),
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
]
@@ -188,13 +190,13 @@ class DeviceAirflowChoices(ChoiceSet):
AIRFLOW_MIXED = 'mixed'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, 'Front to rear'),
(AIRFLOW_REAR_TO_FRONT, 'Rear to front'),
(AIRFLOW_LEFT_TO_RIGHT, 'Left to right'),
(AIRFLOW_RIGHT_TO_LEFT, 'Right to left'),
(AIRFLOW_SIDE_TO_REAR, 'Side to rear'),
(AIRFLOW_PASSIVE, 'Passive'),
(AIRFLOW_MIXED, 'Mixed'),
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
(AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
(AIRFLOW_RIGHT_TO_LEFT, _('Right to left')),
(AIRFLOW_SIDE_TO_REAR, _('Side to rear')),
(AIRFLOW_PASSIVE, _('Passive')),
(AIRFLOW_MIXED, _('Mixed')),
)
@@ -213,12 +215,12 @@ class ModuleStatusChoices(ChoiceSet):
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'),
(STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_STAGED, _('Staged'), 'blue'),
(STATUS_FAILED, _('Failed'), 'red'),
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
]
@@ -318,6 +320,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p'
@@ -429,7 +435,12 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
(_('NEMA (Non-locking)'), (
(TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'),
(TYPE_NEMA_520P, 'NEMA 5-20P'),
@@ -451,7 +462,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_1550P, 'NEMA 15-50P'),
(TYPE_NEMA_1560P, 'NEMA 15-60P'),
)),
('NEMA (Locking)', (
(_('NEMA (Locking)'), (
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
(TYPE_NEMA_L515P, 'NEMA L5-15P'),
(TYPE_NEMA_L520P, 'NEMA L5-20P'),
@@ -474,7 +485,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
(TYPE_NEMA_L2230P, 'NEMA L22-30P'),
)),
('California Style', (
(_('California Style'), (
(TYPE_CS6361C, 'CS6361C'),
(TYPE_CS6365C, 'CS6365C'),
(TYPE_CS8165C, 'CS8165C'),
@@ -482,7 +493,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_CS8365C, 'CS8365C'),
(TYPE_CS8465C, 'CS8465C'),
)),
('International/ITA', (
(_('International/ITA'), (
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
@@ -512,7 +523,7 @@ class PowerPortTypeChoices(ChoiceSet):
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
('Proprietary', (
(_('Proprietary'), (
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
(TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
(TYPE_NEUTRIK_POWERCON_32A, 'Neutrik powerCON (32A)'),
@@ -520,7 +531,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)),
('Other', (
(_('Other'), (
(TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)),
@@ -553,6 +564,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r'
@@ -657,7 +672,12 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
(_('NEMA (Non-locking)'), (
(TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'),
(TYPE_NEMA_520R, 'NEMA 5-20R'),
@@ -679,7 +699,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_1550R, 'NEMA 15-50R'),
(TYPE_NEMA_1560R, 'NEMA 15-60R'),
)),
('NEMA (Locking)', (
(_('NEMA (Locking)'), (
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
(TYPE_NEMA_L515R, 'NEMA L5-15R'),
(TYPE_NEMA_L520R, 'NEMA L5-20R'),
@@ -702,7 +722,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
(TYPE_NEMA_L2230R, 'NEMA L22-30R'),
)),
('California Style', (
(_('California Style'), (
(TYPE_CS6360C, 'CS6360C'),
(TYPE_CS6364C, 'CS6364C'),
(TYPE_CS8164C, 'CS8164C'),
@@ -710,7 +730,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_CS8364C, 'CS8364C'),
(TYPE_CS8464C, 'CS8464C'),
)),
('ITA/International', (
(_('ITA/International'), (
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
@@ -732,7 +752,7 @@ class PowerOutletTypeChoices(ChoiceSet):
('DC', (
(TYPE_DC, 'DC Terminal'),
)),
('Proprietary', (
(_('Proprietary'), (
(TYPE_HDOT_CX, 'HDOT Cx'),
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
(TYPE_NEUTRIK_POWERCON_20A, 'Neutrik powerCON (20A)'),
@@ -741,7 +761,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEUTRIK_POWERCON_TRUE1_TOP, 'Neutrik powerCON TRUE1 TOP'),
(TYPE_UBIQUITI_SMARTPOWER, 'Ubiquiti SmartPower'),
)),
('Other', (
(_('Other'), (
(TYPE_HARDWIRED, 'Hardwired'),
(TYPE_OTHER, 'Other'),
)),
@@ -771,9 +791,9 @@ class InterfaceKindChoices(ChoiceSet):
KIND_WIRELESS = 'wireless'
CHOICES = (
(KIND_PHYSICAL, 'Physical'),
(KIND_VIRTUAL, 'Virtual'),
(KIND_WIRELESS, 'Wireless'),
(KIND_PHYSICAL, _('Physical')),
(KIND_VIRTUAL, _('Virtual')),
(KIND_WIRELESS, _('Wireless')),
)
@@ -809,7 +829,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100GE_CFP4 = '100gbase-x-cfp4'
TYPE_100GE_CXP = '100gbase-x-cxp'
TYPE_100GE_CPAK = '100gbase-x-cpak'
TYPE_100GE_DSFP = '100gbase-x-dsfp'
TYPE_100GE_SFP_DD = '100gbase-x-sfpdd'
TYPE_100GE_QSFP28 = '100gbase-x-qsfp28'
TYPE_100GE_QSFP_DD = '100gbase-x-qsfpdd'
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
@@ -918,15 +941,15 @@ class InterfaceTypeChoices(ChoiceSet):
CHOICES = (
(
'Virtual interfaces',
_('Virtual interfaces'),
(
(TYPE_VIRTUAL, 'Virtual'),
(TYPE_BRIDGE, 'Bridge'),
(TYPE_LAG, 'Link Aggregation Group (LAG)'),
(TYPE_VIRTUAL, _('Virtual')),
(TYPE_BRIDGE, _('Bridge')),
(TYPE_LAG, _('Link Aggregation Group (LAG)')),
),
),
(
'Ethernet (fixed)',
_('Ethernet (fixed)'),
(
(TYPE_100ME_FX, '100BASE-FX (10/100ME FIBER)'),
(TYPE_100ME_LFX, '100BASE-LFX (10/100ME FIBER)'),
@@ -940,7 +963,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Ethernet (modular)',
_('Ethernet (modular)'),
(
(TYPE_1GE_GBIC, 'GBIC (1GE)'),
(TYPE_1GE_SFP, 'SFP (1GE)'),
@@ -958,7 +981,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
(TYPE_100GE_DSFP, 'DSFP (100GE)'),
(TYPE_100GE_SFP_DD, 'SFP-DD (100GE)'),
(TYPE_100GE_QSFP28, 'QSFP28 (100GE)'),
(TYPE_100GE_QSFP_DD, 'QSFP-DD (100GE)'),
(TYPE_200GE_QSFP56, 'QSFP56 (200GE)'),
(TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'),
(TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'),
@@ -970,7 +996,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Ethernet (backplane)',
_('Ethernet (backplane)'),
(
(TYPE_1GE_KX, '1000BASE-KX (1GE)'),
(TYPE_10GE_KR, '10GBASE-KR (10GE)'),
@@ -984,7 +1010,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Wireless',
_('Wireless'),
(
(TYPE_80211A, 'IEEE 802.11a'),
(TYPE_80211G, 'IEEE 802.11b/g'),
@@ -998,7 +1024,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Cellular',
_('Cellular'),
(
(TYPE_GSM, 'GSM'),
(TYPE_CDMA, 'CDMA'),
@@ -1045,7 +1071,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Serial',
_('Serial'),
(
(TYPE_T1, 'T1 (1.544 Mbps)'),
(TYPE_E1, 'E1 (2.048 Mbps)'),
@@ -1060,7 +1086,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Coaxial',
_('Coaxial'),
(
(TYPE_DOCSIS, 'DOCSIS'),
)
@@ -1077,7 +1103,7 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Stacking',
_('Stacking'),
(
(TYPE_STACKWISE, 'Cisco StackWise'),
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
@@ -1096,9 +1122,9 @@ class InterfaceTypeChoices(ChoiceSet):
)
),
(
'Other',
_('Other'),
(
(TYPE_OTHER, 'Other'),
(TYPE_OTHER, _('Other')),
)
),
)
@@ -1125,9 +1151,9 @@ class InterfaceDuplexChoices(ChoiceSet):
DUPLEX_AUTO = 'auto'
CHOICES = (
(DUPLEX_HALF, 'Half'),
(DUPLEX_FULL, 'Full'),
(DUPLEX_AUTO, 'Auto'),
(DUPLEX_HALF, _('Half')),
(DUPLEX_FULL, _('Full')),
(DUPLEX_AUTO, _('Auto')),
)
@@ -1138,9 +1164,9 @@ class InterfaceModeChoices(ChoiceSet):
MODE_TAGGED_ALL = 'tagged-all'
CHOICES = (
(MODE_ACCESS, 'Access'),
(MODE_TAGGED, 'Tagged'),
(MODE_TAGGED_ALL, 'Tagged (All)'),
(MODE_ACCESS, _('Access')),
(MODE_TAGGED, _('Tagged')),
(MODE_TAGGED_ALL, _('Tagged (All)')),
)
@@ -1169,7 +1195,7 @@ class InterfacePoETypeChoices(ChoiceSet):
CHOICES = (
(
'IEEE Standard',
_('IEEE Standard'),
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
@@ -1178,12 +1204,12 @@ class InterfacePoETypeChoices(ChoiceSet):
)
),
(
'Passive',
_('Passive'),
(
(PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'),
(PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'),
(PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'),
(PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'),
(PASSIVE_24V_2PAIR, _('Passive 24V (2-pair)')),
(PASSIVE_24V_4PAIR, _('Passive 24V (4-pair)')),
(PASSIVE_48V_2PAIR, _('Passive 48V (2-pair)')),
(PASSIVE_48V_4PAIR, _('Passive 48V (4-pair)')),
)
),
)
@@ -1245,7 +1271,7 @@ class PortTypeChoices(ChoiceSet):
CHOICES = (
(
'Copper',
_('Copper'),
(
(TYPE_8P8C, '8P8C'),
(TYPE_8P6C, '8P6C'),
@@ -1268,7 +1294,7 @@ class PortTypeChoices(ChoiceSet):
),
),
(
'Fiber Optic',
_('Fiber Optic'),
(
(TYPE_FC, 'FC'),
(TYPE_LC, 'LC'),
@@ -1301,9 +1327,9 @@ class PortTypeChoices(ChoiceSet):
),
),
(
'Other',
_('Other'),
(
(TYPE_OTHER, 'Other'),
(TYPE_OTHER, _('Other')),
)
)
)
@@ -1341,7 +1367,7 @@ class CableTypeChoices(ChoiceSet):
CHOICES = (
(
'Copper', (
_('Copper'), (
(TYPE_CAT3, 'CAT3'),
(TYPE_CAT5, 'CAT5'),
(TYPE_CAT5E, 'CAT5e'),
@@ -1357,7 +1383,7 @@ class CableTypeChoices(ChoiceSet):
),
),
(
'Fiber', (
_('Fiber'), (
(TYPE_MMF, 'Multimode Fiber'),
(TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
(TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
@@ -1370,7 +1396,7 @@ class CableTypeChoices(ChoiceSet):
(TYPE_AOC, 'Active Optical Cabling (AOC)'),
),
),
(TYPE_POWER, 'Power'),
(TYPE_POWER, _('Power')),
)
@@ -1381,9 +1407,9 @@ class LinkStatusChoices(ChoiceSet):
STATUS_DECOMMISSIONING = 'decommissioning'
CHOICES = (
(STATUS_CONNECTED, 'Connected', 'green'),
(STATUS_PLANNED, 'Planned', 'blue'),
(STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
(STATUS_CONNECTED, _('Connected'), 'green'),
(STATUS_PLANNED, _('Planned'), 'blue'),
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
)
@@ -1400,12 +1426,12 @@ class CableLengthUnitChoices(ChoiceSet):
UNIT_INCH = 'in'
CHOICES = (
(UNIT_KILOMETER, 'Kilometers'),
(UNIT_METER, 'Meters'),
(UNIT_CENTIMETER, 'Centimeters'),
(UNIT_MILE, 'Miles'),
(UNIT_FOOT, 'Feet'),
(UNIT_INCH, 'Inches'),
(UNIT_KILOMETER, _('Kilometers')),
(UNIT_METER, _('Meters')),
(UNIT_CENTIMETER, _('Centimeters')),
(UNIT_MILE, _('Miles')),
(UNIT_FOOT, _('Feet')),
(UNIT_INCH, _('Inches')),
)
@@ -1420,10 +1446,10 @@ class WeightUnitChoices(ChoiceSet):
UNIT_OUNCE = 'oz'
CHOICES = (
(UNIT_KILOGRAM, 'Kilograms'),
(UNIT_GRAM, 'Grams'),
(UNIT_POUND, 'Pounds'),
(UNIT_OUNCE, 'Ounces'),
(UNIT_KILOGRAM, _('Kilograms')),
(UNIT_GRAM, _('Grams')),
(UNIT_POUND, _('Pounds')),
(UNIT_OUNCE, _('Ounces')),
)
@@ -1456,10 +1482,10 @@ class PowerFeedStatusChoices(ChoiceSet):
STATUS_FAILED = 'failed'
CHOICES = [
(STATUS_OFFLINE, 'Offline', 'gray'),
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_PLANNED, 'Planned', 'blue'),
(STATUS_FAILED, 'Failed', 'red'),
(STATUS_OFFLINE, _('Offline'), 'gray'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, _('Planned'), 'blue'),
(STATUS_FAILED, _('Failed'), 'red'),
]
@@ -1469,8 +1495,8 @@ class PowerFeedTypeChoices(ChoiceSet):
TYPE_REDUNDANT = 'redundant'
CHOICES = (
(TYPE_PRIMARY, 'Primary', 'green'),
(TYPE_REDUNDANT, 'Redundant', 'cyan'),
(TYPE_PRIMARY, _('Primary'), 'green'),
(TYPE_REDUNDANT, _('Redundant'), 'cyan'),
)
@@ -1491,8 +1517,8 @@ class PowerFeedPhaseChoices(ChoiceSet):
PHASE_3PHASE = 'three-phase'
CHOICES = (
(PHASE_SINGLE, 'Single phase'),
(PHASE_3PHASE, 'Three-phase'),
(PHASE_SINGLE, _('Single phase')),
(PHASE_3PHASE, _('Three-phase')),
)
@@ -1507,7 +1533,7 @@ class VirtualDeviceContextStatusChoices(ChoiceSet):
STATUS_OFFLINE = 'offline'
CHOICES = [
(STATUS_ACTIVE, 'Active', 'green'),
(STATUS_PLANNED, 'Planned', 'cyan'),
(STATUS_OFFLINE, 'Offline', 'red'),
(STATUS_ACTIVE, _('Active'), 'green'),
(STATUS_PLANNED, _('Planned'), 'cyan'),
(STATUS_OFFLINE, _('Offline'), 'red'),
]

View File

@@ -11,11 +11,14 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff,
#
RACK_U_HEIGHT_DEFAULT = 42
RACK_U_HEIGHT_MAX = 100
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30
RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15
RACK_STARTING_UNIT_DEFAULT = 1
#
# RearPorts

View File

@@ -6,7 +6,6 @@ from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains
__all__ = (
'ASNField',
'MACAddressField',
'PathField',
'WWNField',

View File

@@ -1,5 +1,5 @@
import django_filters
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.utils.translation import gettext as _
from extras.filtersets import LocalConfigContextFilterSet
@@ -323,8 +323,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit'
]
def search(self, queryset, name, value):
@@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'),
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username',
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
to_field_name='username',
label=_('User (name)'),
)
@@ -696,6 +696,9 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
poe_type = django_filters.MultipleChoiceFilter(
choices=InterfacePoETypeChoices
)
rf_role = django_filters.MultipleChoiceFilter(
choices=WirelessRoleChoices
)
class Meta:
model = InterfaceTemplate
@@ -811,7 +814,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'napalm_driver', 'description']
fields = ['id', 'name', 'slug', 'description']
class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
@@ -837,12 +840,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
label=_('Device type (ID)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device_role_id',
field_name='role_id',
queryset=DeviceRole.objects.all(),
label=_('Role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='device_role__slug',
field_name='role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Role (slug)'),
@@ -941,6 +944,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
method='_has_primary_ip',
label=_('Has a primary IP'),
)
has_oob_ip = django_filters.BooleanFilter(
method='_has_oob_ip',
label=_('Has an out-of-band IP'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='virtual_chassis',
queryset=VirtualChassis.objects.all(),
@@ -996,10 +1003,15 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
queryset=IPAddress.objects.all(),
label=_('Primary IPv6 (ID)'),
)
oob_ip_id = django_filters.ModelMultipleChoiceFilter(
field_name='oob_ip',
queryset=IPAddress.objects.all(),
label=_('OOB IP (ID)'),
)
class Meta:
model = Device
fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority']
fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority']
def search(self, queryset, name, value):
if not value.strip():
@@ -1020,6 +1032,12 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
return queryset.filter(params)
return queryset.exclude(params)
def _has_oob_ip(self, queryset, name, value):
params = Q(oob_ip__isnull=False)
if value:
return queryset.filter(params)
return queryset.exclude(params)
def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value)
@@ -1077,10 +1095,13 @@ class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(identifier=value.strip())
).distinct()
qs_filter = Q(name__icontains=value)
try:
qs_filter |= Q(identifier=int(value))
except ValueError:
pass
return queryset.filter(qs_filter).distinct()
def _has_primary_ip(self, queryset, name, value):
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
@@ -1219,6 +1240,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label=_('Device (name)'),
)
device_type_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type',
queryset=DeviceType.objects.all(),
label=_('Device type (ID)'),
)
device_type = django_filters.ModelMultipleChoiceFilter(
field_name='device__device_type__model',
queryset=DeviceType.objects.all(),
to_field_name='model',
label=_('Device type (model)'),
)
role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
role = django_filters.ModelMultipleChoiceFilter(
field_name='device__role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__virtual_chassis',
queryset=VirtualChassis.objects.all(),
@@ -1230,6 +1273,18 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label=_('Virtual Chassis'),
)
# TODO: Remove in v4.0
device_role_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__role',
queryset=DeviceRole.objects.all(),
label=_('Device role (ID)'),
)
device_role = django_filters.ModelMultipleChoiceFilter(
field_name='device__role__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label=_('Device role (slug)'),
)
def search(self, queryset, name, value):
if not value.strip():
@@ -1837,7 +1892,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',

View File

@@ -1,7 +1,7 @@
from django import forms
from dcim.models import *
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from utilities.forms import BootstrapMixin, form_from_model
@@ -32,10 +32,12 @@ class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCre
widget=forms.MultipleHiddenInput()
)
description = forms.CharField(
label=_('Description'),
max_length=100,
required=False
)
tags = DynamicModelMultipleChoiceField(
label=_('Tags'),
queryset=Tag.objects.all(),
required=False
)
@@ -76,14 +78,14 @@ class PowerOutletBulkCreateForm(
class InterfaceBulkCreateForm(
form_from_model(Interface, [
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type',
'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', 'rf_role'
]),
DeviceBulkAddComponentForm
):
model = Interface
field_order = (
'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode',
'poe_type', 'mark_connected', 'description', 'tags',
'poe_type', 'mark_connected', 'rf_role', 'description', 'tags',
)

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms.array import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -56,6 +56,7 @@ __all__ = (
class RegionImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Region.objects.all(),
required=False,
to_field_name='name',
@@ -69,6 +70,7 @@ class RegionImportForm(NetBoxModelImportForm):
class SiteGroupImportForm(NetBoxModelImportForm):
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
@@ -82,22 +84,26 @@ class SiteGroupImportForm(NetBoxModelImportForm):
class SiteImportForm(NetBoxModelImportForm):
status = CSVChoiceField(
label=_('Status'),
choices=SiteStatusChoices,
help_text=_('Operational status')
)
region = CSVModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned region')
)
group = CSVModelChoiceField(
label=_('Group'),
queryset=SiteGroup.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned group')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
@@ -119,11 +125,13 @@ class SiteImportForm(NetBoxModelImportForm):
class LocationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
help_text=_('Assigned site')
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Location.objects.all(),
required=False,
to_field_name='name',
@@ -133,10 +141,12 @@ class LocationImportForm(NetBoxModelImportForm):
}
)
status = CSVChoiceField(
label=_('Status'),
choices=LocationStatusChoices,
help_text=_('Operational status')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
@@ -161,45 +171,54 @@ class RackRoleImportForm(NetBoxModelImportForm):
class RackImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name'
)
location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
to_field_name='name'
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Name of assigned tenant')
)
status = CSVChoiceField(
label=_('Status'),
choices=RackStatusChoices,
help_text=_('Operational status')
)
role = CSVModelChoiceField(
label=_('Role'),
queryset=RackRole.objects.all(),
required=False,
to_field_name='name',
help_text=_('Name of assigned role')
)
type = CSVChoiceField(
label=_('Type'),
choices=RackTypeChoices,
required=False,
help_text=_('Rack type')
)
width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices,
help_text=_('Rail-to-rail width (in inches)')
)
outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices,
required=False,
help_text=_('Unit for outer dimensions')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for rack weights')
@@ -225,27 +244,32 @@ class RackImportForm(NetBoxModelImportForm):
class RackReservationImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
help_text=_('Parent site')
)
location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text=_("Rack's location (if any)")
)
rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
to_field_name='name',
help_text=_('Rack')
)
units = SimpleArrayField(
label=_('Units'),
base_field=forms.IntegerField(),
required=True,
help_text=_('Comma-separated list of individual unit numbers')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
@@ -282,21 +306,25 @@ class ManufacturerImportForm(NetBoxModelImportForm):
class DeviceTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text=_('The manufacturer which produces this device type')
)
default_platform = forms.ModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
to_field_name='name',
required=False,
help_text=_('The default platform for devices of this type (optional)')
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
help_text=_('Device weight'),
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for device weight')
@@ -306,20 +334,23 @@ class DeviceTypeImportForm(NetBoxModelImportForm):
model = DeviceType
fields = [
'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments',
'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', 'tags',
]
class ModuleTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
help_text=_('Module weight'),
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for module weight')
@@ -327,11 +358,12 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments']
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
class DeviceRoleImportForm(NetBoxModelImportForm):
config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
@@ -350,12 +382,14 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class PlatformImportForm(NetBoxModelImportForm):
slug = SlugField()
manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False,
to_field_name='name',
help_text=_('Limit platform assignments to this manufacturer')
)
config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
@@ -365,49 +399,57 @@ class PlatformImportForm(NetBoxModelImportForm):
class Meta:
model = Platform
fields = (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
)
class BaseDeviceImportForm(NetBoxModelImportForm):
device_role = CSVModelChoiceField(
role = CSVModelChoiceField(
label=_('Device role'),
queryset=DeviceRole.objects.all(),
to_field_name='name',
help_text=_('Assigned role')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned tenant')
)
manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text=_('Device type manufacturer')
)
device_type = CSVModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(),
to_field_name='model',
help_text=_('Device type model')
)
platform = CSVModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned platform')
)
status = CSVChoiceField(
label=_('Status'),
choices=DeviceStatusChoices,
help_text=_('Operational status')
)
virtual_chassis = CSVModelChoiceField(
label=_('Virtual chassis'),
queryset=VirtualChassis.objects.all(),
to_field_name='name',
required=False,
help_text=_('Virtual chassis')
)
cluster = CSVModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
to_field_name='name',
required=False,
@@ -430,45 +472,53 @@ class BaseDeviceImportForm(NetBoxModelImportForm):
class DeviceImportForm(BaseDeviceImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
help_text=_('Assigned site')
)
location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text=_("Assigned location (if any)")
)
rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
help_text=_("Assigned rack (if any)")
)
face = CSVChoiceField(
label=_('Face'),
choices=DeviceFaceChoices,
required=False,
help_text=_('Mounted rack face')
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text=_('Parent device (for child devices)')
)
device_bay = CSVModelChoiceField(
label=_('Device bay'),
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(
label=_('Airflow'),
choices=DeviceAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
config_template = CSVModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
to_field_name='name',
required=False,
@@ -477,9 +527,10 @@ 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', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
'name', 'role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow',
'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments',
'tags',
]
def __init__(self, data=None, *args, **kwargs):
@@ -522,29 +573,35 @@ class DeviceImportForm(BaseDeviceImportForm):
class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('The device in which this module is installed')
)
module_bay = CSVModelChoiceField(
label=_('Module bay'),
queryset=ModuleBay.objects.all(),
to_field_name='name',
help_text=_('The module bay in which this module is installed')
)
module_type = CSVModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(),
to_field_name='model',
help_text=_('The type of module')
)
status = CSVChoiceField(
label=_('Status'),
choices=ModuleStatusChoices,
help_text=_('Operational status')
)
replicate_components = forms.BooleanField(
label=_('Replicate components'),
required=False,
help_text=_('Automatically populate components associated with this module type (enabled by default)')
)
adopt_components = forms.BooleanField(
label=_('Adopt components'),
required=False,
help_text=_('Adopt already existing components')
)
@@ -578,15 +635,18 @@ class ModuleImportForm(ModuleCommonForm, NetBoxModelImportForm):
class ConsolePortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False,
help_text=_('Port type')
)
speed = CSVTypedChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
@@ -601,15 +661,18 @@ class ConsolePortImportForm(NetBoxModelImportForm):
class ConsoleServerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False,
help_text=_('Port type')
)
speed = CSVTypedChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices,
coerce=int,
empty_value=None,
@@ -624,10 +687,12 @@ class ConsoleServerPortImportForm(NetBoxModelImportForm):
class PowerPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices,
required=False,
help_text=_('Port type')
@@ -642,21 +707,25 @@ class PowerPortImportForm(NetBoxModelImportForm):
class PowerOutletImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices,
required=False,
help_text=_('Outlet type')
)
power_port = CSVModelChoiceField(
label=_('Power port'),
queryset=PowerPort.objects.all(),
required=False,
to_field_name='name',
help_text=_('Local power port which feeds this outlet')
)
feed_leg = CSVChoiceField(
label=_('Feed lag'),
choices=PowerOutletFeedLegChoices,
required=False,
help_text=_('Electrical phase (for three-phase circuits)')
@@ -691,63 +760,75 @@ class PowerOutletImportForm(NetBoxModelImportForm):
class InterfaceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent interface')
)
bridge = CSVModelChoiceField(
label=_('Bridge'),
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text=_('Bridged interface')
)
lag = CSVModelChoiceField(
label=_('Lag'),
queryset=Interface.objects.all(),
required=False,
to_field_name='name',
help_text=_('Parent LAG interface')
)
vdcs = CSVModelMultipleChoiceField(
label=_('Vdcs'),
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
help_text=_('VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")')
)
type = CSVChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices,
help_text=_('Physical medium')
)
duplex = CSVChoiceField(
label=_('Duplex'),
choices=InterfaceDuplexChoices,
required=False
)
poe_mode = CSVChoiceField(
label=_('Poe mode'),
choices=InterfacePoEModeChoices,
required=False,
help_text=_('PoE mode')
)
poe_type = CSVChoiceField(
label=_('Poe type'),
choices=InterfacePoETypeChoices,
required=False,
help_text=_('PoE type')
)
mode = CSVChoiceField(
label=_('Mode'),
choices=InterfaceModeChoices,
required=False,
help_text=_('IEEE 802.1Q operational mode (for L2 interfaces)')
)
vrf = CSVModelChoiceField(
label=_('VRF'),
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text=_('Assigned VRF')
)
rf_role = CSVChoiceField(
label=_('Rf role'),
choices=WirelessRoleChoices,
required=False,
help_text=_('Wireless role (AP/station)')
@@ -791,15 +872,18 @@ class InterfaceImportForm(NetBoxModelImportForm):
class FrontPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
rear_port = CSVModelChoiceField(
label=_('Rear port'),
queryset=RearPort.objects.all(),
to_field_name='name',
help_text=_('Corresponding rear port')
)
type = CSVChoiceField(
label=_('Type'),
choices=PortTypeChoices,
help_text=_('Physical medium classification')
)
@@ -836,10 +920,12 @@ class FrontPortImportForm(NetBoxModelImportForm):
class RearPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
type = CSVChoiceField(
label=_('Type'),
help_text=_('Physical medium classification'),
choices=PortTypeChoices,
)
@@ -851,6 +937,7 @@ class RearPortImportForm(NetBoxModelImportForm):
class ModuleBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
@@ -862,10 +949,12 @@ class ModuleBayImportForm(NetBoxModelImportForm):
class DeviceBayImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
installed_device = CSVModelChoiceField(
label=_('Installed device'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
@@ -908,32 +997,38 @@ class DeviceBayImportForm(NetBoxModelImportForm):
class InventoryItemImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name'
)
role = CSVModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(),
to_field_name='name',
required=False
)
manufacturer = CSVModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
required=False
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(),
to_field_name='name',
required=False,
help_text=_('Parent inventory item')
)
component_type = CSVContentTypeField(
label=_('Component type'),
queryset=ContentType.objects.all(),
limit_choices_to=MODULAR_COMPONENT_MODELS,
required=False,
help_text=_('Component Type')
)
component_name = forms.CharField(
label=_('Compnent name'),
required=False,
help_text=_('Component Name')
)
@@ -1001,52 +1096,62 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class CableImportForm(NetBoxModelImportForm):
# Termination A
side_a_device = CSVModelChoiceField(
label=_('Side a device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Side A device')
)
side_a_type = CSVContentTypeField(
label=_('Side a type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side A type')
)
side_a_name = forms.CharField(
label=_('Side a name'),
help_text=_('Side A component name')
)
# Termination B
side_b_device = CSVModelChoiceField(
label=_('Side b device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text=_('Side B device')
)
side_b_type = CSVContentTypeField(
label=_('Side b type'),
queryset=ContentType.objects.all(),
limit_choices_to=CABLE_TERMINATION_MODELS,
help_text=_('Side B type')
)
side_b_name = forms.CharField(
label=_('Side b name'),
help_text=_('Side B component name')
)
# Cable attributes
status = CSVChoiceField(
label=_('Status'),
choices=LinkStatusChoices,
required=False,
help_text=_('Connection status')
)
type = CSVChoiceField(
label=_('Type'),
choices=CableTypeChoices,
required=False,
help_text=_('Physical medium classification')
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',
help_text=_('Assigned tenant')
)
length_unit = CSVChoiceField(
label=_('Length unit'),
choices=CableLengthUnitChoices,
required=False,
help_text=_('Length unit')
@@ -1109,6 +1214,7 @@ class CableImportForm(NetBoxModelImportForm):
class VirtualChassisImportForm(NetBoxModelImportForm):
master = CSVModelChoiceField(
label=_('Master'),
queryset=Device.objects.all(),
to_field_name='name',
required=False,
@@ -1126,11 +1232,13 @@ class VirtualChassisImportForm(NetBoxModelImportForm):
class PowerPanelImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
help_text=_('Name of parent site')
)
location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
to_field_name='name'
@@ -1152,40 +1260,54 @@ class PowerPanelImportForm(NetBoxModelImportForm):
class PowerFeedImportForm(NetBoxModelImportForm):
site = CSVModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
to_field_name='name',
help_text=_('Assigned site')
)
power_panel = CSVModelChoiceField(
label=_('Power panel'),
queryset=PowerPanel.objects.all(),
to_field_name='name',
help_text=_('Upstream power panel')
)
location = CSVModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
to_field_name='name',
required=False,
help_text=_("Rack's location (if any)")
)
rack = CSVModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
to_field_name='name',
required=False,
help_text=_('Rack')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned tenant')
)
status = CSVChoiceField(
label=_('Status'),
choices=PowerFeedStatusChoices,
help_text=_('Operational status')
)
type = CSVChoiceField(
label=_('Type'),
choices=PowerFeedTypeChoices,
help_text=_('Primary or redundant')
)
supply = CSVChoiceField(
label=_('Supply'),
choices=PowerFeedSupplyChoices,
help_text=_('Supply type (AC/DC)')
)
phase = CSVChoiceField(
label=_('Phase'),
choices=PowerFeedPhaseChoices,
help_text=_('Single or three-phase')
)
@@ -1194,7 +1316,7 @@ class PowerFeedImportForm(NetBoxModelImportForm):
model = PowerFeed
fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags',
'voltage', 'amperage', 'max_utilization', 'tenant', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@@ -1221,11 +1343,13 @@ class PowerFeedImportForm(NetBoxModelImportForm):
class VirtualDeviceContextImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
to_field_name='name',
help_text='Assigned role'
)
tenant = CSVModelChoiceField(
label=_('Tenant'),
queryset=Tenant.objects.all(),
required=False,
to_field_name='name',

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -47,7 +47,7 @@ class InterfaceCommonForm(forms.Form):
# Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned."
'mode': _("An access interface cannot have tagged VLANs assigned.")
})
# Remove all tagged VLAN assignments from "tagged all" interfaces
@@ -61,8 +61,10 @@ class InterfaceCommonForm(forms.Form):
if invalid_vlans:
raise forms.ValidationError({
'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"
'tagged_vlans': _(
"The tagged VLANs ({vlans}) must belong to the same site as the interface's parent device/VM, "
"or they must be global"
).format(vlans=', '.join(invalid_vlans))
})
@@ -105,7 +107,7 @@ class ModuleCommonForm(forms.Form):
# 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"
_("Cannot install module with placeholder values in a module bay with no position defined.")
)
resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position)
@@ -114,12 +116,17 @@ class ModuleCommonForm(forms.Form):
# 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"
_("Cannot adopt {name} '{resolved_name}' as it already belongs to a module").format(
name=template.component_model.__name__,
resolved_name=resolved_name
)
)
# 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"
_("{name} - {resolved_name} already exists").format(
name=template.component_model.__name__,
resolved_name=resolved_name
)
)

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit, CircuitTermination
from dcim.models import *

View File

@@ -1,6 +1,6 @@
from django import forms
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -56,9 +56,11 @@ __all__ = (
class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
name = forms.CharField(
label=_('Name'),
required=False
)
label = forms.CharField(
label=_('Label'),
required=False
)
region_id = DynamicModelMultipleChoiceField(
@@ -102,13 +104,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Virtual Chassis')
)
device_type_id = DynamicModelMultipleChoiceField(
queryset=DeviceType.objects.all(),
required=False,
label=_('Device type')
)
role_id = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
label=_('Device role')
)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={
'site_id': '$site_id',
'location_id': '$location_id',
'virtual_chassis_id': '$virtual_chassis_id'
'virtual_chassis_id': '$virtual_chassis_id',
'device_type_id': '$device_type_id',
'role_id': '$role_id'
},
label=_('Device')
)
@@ -118,7 +132,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Region
fieldsets = (
(None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
(_('Contacts'), ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -132,7 +146,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = SiteGroup
fieldsets = (
(None, ('q', 'filter_id', 'tag', 'parent_id')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
(_('Contacts'), ('contact', 'contact_role', 'contact_group'))
)
parent_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
@@ -146,11 +160,12 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Site
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('status', 'region_id', 'group_id', 'asn_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
(_('Attributes'), ('status', 'region_id', 'group_id', 'asn_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=SiteStatusChoices,
required=False
)
@@ -176,9 +191,9 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
(_('Attributes'), ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -209,6 +224,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
label=_('Parent')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=LocationStatusChoices,
required=False
)
@@ -224,12 +240,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
model = Rack
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
(_('Function'), ('status', 'role_id')),
(_('Hardware'), ('type', 'width', 'serial', 'asset_tag')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -259,14 +275,17 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
label=_('Location')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=RackStatusChoices,
required=False
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=RackTypeChoices,
required=False
)
width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False
)
@@ -277,21 +296,26 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
label=_('Role')
)
serial = forms.CharField(
label=_('Serial'),
required=False
)
asset_tag = forms.CharField(
label=_('Asset tag'),
required=False
)
tag = TagFilterField(model)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
min_value=1
)
max_weight = forms.IntegerField(
label=_('Max weight'),
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
@@ -300,12 +324,12 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
class RackElevationFilterForm(RackFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
('Function', ('status', 'role_id')),
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Weight', ('weight', 'max_weight', 'weight_unit')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
(_('Function'), ('status', 'role_id')),
(_('Hardware'), ('type', 'width', 'serial', 'asset_tag')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
)
id = DynamicModelMultipleChoiceField(
queryset=Rack.objects.all(),
@@ -322,9 +346,9 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = RackReservation
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('User', ('user_id',)),
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
(_('User'), ('user_id',)),
(_('Rack'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -364,7 +388,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack')
)
user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
queryset=get_user_model().objects.all(),
required=False,
label=_('User'),
widget=APISelectMultiple(
@@ -378,7 +402,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Manufacturer
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Contacts', ('contact', 'contact_role', 'contact_group'))
(_('Contacts'), ('contact', 'contact_role', 'contact_group'))
)
tag = TagFilterField(model)
@@ -387,13 +411,13 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
model = DeviceType
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')),
('Images', ('has_front_image', 'has_rear_image')),
('Components', (
(_('Hardware'), ('manufacturer_id', 'default_platform_id', 'part_number', 'subdevice_role', 'airflow')),
(_('Images'), ('has_front_image', 'has_rear_image')),
(_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items',
)),
('Weight', ('weight', 'weight_unit')),
(_('Weight'), ('weight', 'weight_unit')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -406,98 +430,103 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
label=_('Default platform')
)
part_number = forms.CharField(
label=_('Part number'),
required=False
)
subdevice_role = forms.MultipleChoiceField(
label=_('Subdevice role'),
choices=add_blank_choice(SubdeviceRoleChoices),
required=False
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
has_front_image = forms.NullBooleanField(
required=False,
label='Has a front image',
label=_('Has a front image'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_rear_image = forms.NullBooleanField(
required=False,
label='Has a rear image',
label=_('Has a rear image'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
label=_('Has console ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
label=_('Has console server ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
label=_('Has power ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
label=_('Has power outlets'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
label=_('Has interfaces'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
label=_('Has pass-through ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
device_bays = forms.NullBooleanField(
required=False,
label='Has device bays',
label=_('Has device bays'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
module_bays = forms.NullBooleanField(
required=False,
label='Has module bays',
label=_('Has module bays'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
inventory_items = forms.NullBooleanField(
required=False,
label='Has inventory items',
label=_('Has inventory items'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
weight = forms.DecimalField(
label=_('Weight'),
required=False
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
@@ -507,12 +536,12 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'part_number')),
('Components', (
(_('Hardware'), ('manufacturer_id', 'part_number')),
(_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports',
)),
('Weight', ('weight', 'weight_unit')),
(_('Weight'), ('weight', 'weight_unit')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -521,55 +550,58 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
fetch_trigger='open'
)
part_number = forms.CharField(
label=_('Part number'),
required=False
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
label=_('Has console ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
label=_('Has console server ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
label=_('Has power ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
label=_('Has power outlets'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
label=_('Has interfaces'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
label=_('Has pass-through ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
weight = forms.DecimalField(
label=_('Weight'),
required=False
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
@@ -609,15 +641,17 @@ class DeviceFilterForm(
model = Device
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
('Components', (
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Operation'), ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')),
(_('Hardware'), ('manufacturer_id', 'device_type_id', 'platform_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
(_('Components'), (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)),
('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
(_('Miscellaneous'), (
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
))
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -682,22 +716,26 @@ class DeviceFilterForm(
label=_('Platform')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=DeviceStatusChoices,
required=False
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(DeviceAirflowChoices),
required=False
)
serial = forms.CharField(
label=_('Serial'),
required=False
)
asset_tag = forms.CharField(
label=_('Asset tag'),
required=False
)
mac_address = forms.CharField(
required=False,
label='MAC address'
label=_('MAC address')
)
config_template_id = DynamicModelMultipleChoiceField(
queryset=ConfigTemplate.objects.all(),
@@ -706,56 +744,63 @@ class DeviceFilterForm(
)
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
label=_('Has a primary IP'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
has_oob_ip = forms.NullBooleanField(
required=False,
label='Has an OOB IP',
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
virtual_chassis_member = forms.NullBooleanField(
required=False,
label='Virtual chassis member',
label=_('Virtual chassis member'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_ports = forms.NullBooleanField(
required=False,
label='Has console ports',
label=_('Has console ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
console_server_ports = forms.NullBooleanField(
required=False,
label='Has console server ports',
label=_('Has console server ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_ports = forms.NullBooleanField(
required=False,
label='Has power ports',
label=_('Has power ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
power_outlets = forms.NullBooleanField(
required=False,
label='Has power outlets',
label=_('Has power outlets'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
interfaces = forms.NullBooleanField(
required=False,
label='Has interfaces',
label=_('Has interfaces'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
pass_through_ports = forms.NullBooleanField(
required=False,
label='Has pass-through ports',
label=_('Has pass-through ports'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
@@ -770,8 +815,8 @@ class VirtualDeviceContextFilterForm(
model = VirtualDeviceContext
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('device', 'status', 'has_primary_ip')),
('Tenant', ('tenant_group_id', 'tenant_id')),
(_('Attributes'), ('device', 'status', 'has_primary_ip')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
device = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -780,12 +825,13 @@ class VirtualDeviceContextFilterForm(
fetch_trigger='open'
)
status = forms.MultipleChoiceField(
label=_('Status'),
required=False,
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
)
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
label=_('Has a primary IP'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
@@ -797,7 +843,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
model = Module
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Hardware', ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
(_('Hardware'), ('manufacturer_id', 'module_type_id', 'status', 'serial', 'asset_tag')),
)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
@@ -815,13 +861,16 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
fetch_trigger='open'
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=ModuleStatusChoices,
required=False
)
serial = forms.CharField(
label=_('Serial'),
required=False
)
asset_tag = forms.CharField(
label=_('Asset tag'),
required=False
)
tag = TagFilterField(model)
@@ -831,8 +880,8 @@ class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = VirtualChassis
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
(_('Location'), ('region_id', 'site_group_id', 'site_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -860,9 +909,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = Cable
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('site_id', 'location_id', 'rack_id', 'device_id')),
('Attributes', ('type', 'status', 'color', 'length', 'length_unit')),
('Tenant', ('tenant_group_id', 'tenant_id')),
(_('Location'), ('site_id', 'location_id', 'rack_id', 'device_id')),
(_('Attributes'), ('type', 'status', 'color', 'length', 'length_unit')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -908,20 +957,25 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Device')
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=add_blank_choice(CableTypeChoices),
required=False
)
status = forms.MultipleChoiceField(
label=_('Status'),
required=False,
choices=add_blank_choice(LinkStatusChoices)
)
color = ColorField(
label=_('Color'),
required=False
)
length = forms.IntegerField(
label=_('Length'),
required=False
)
length_unit = forms.ChoiceField(
label=_('Length unit'),
choices=add_blank_choice(CableLengthUnitChoices),
required=False
)
@@ -932,8 +986,8 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = PowerPanel
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')),
('Contacts', ('contact', 'contact_role', 'contact_group')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -966,12 +1020,13 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class PowerFeedFilterForm(NetBoxModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = PowerFeed
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -1010,28 +1065,35 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
label=_('Rack')
)
status = forms.MultipleChoiceField(
label=_('Status'),
choices=PowerFeedStatusChoices,
required=False
)
type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(PowerFeedTypeChoices),
required=False
)
supply = forms.ChoiceField(
label=_('Supply'),
choices=add_blank_choice(PowerFeedSupplyChoices),
required=False
)
phase = forms.ChoiceField(
label=_('Phase'),
choices=add_blank_choice(PowerFeedPhaseChoices),
required=False
)
voltage = forms.IntegerField(
label=_('Voltage'),
required=False
)
amperage = forms.IntegerField(
label=_('Amperage'),
required=False
)
max_utilization = forms.IntegerField(
label=_('Max utilization'),
required=False
)
tag = TagFilterField(model)
@@ -1043,12 +1105,14 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm):
class CabledFilterForm(forms.Form):
cabled = forms.NullBooleanField(
label=_('Cabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
occupied = forms.NullBooleanField(
label=_('Occupied'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
@@ -1058,6 +1122,7 @@ class CabledFilterForm(forms.Form):
class PathEndpointFilterForm(CabledFilterForm):
connected = forms.NullBooleanField(
label=_('Connected'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
@@ -1069,15 +1134,18 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = ConsolePort
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
speed = forms.MultipleChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices,
required=False
)
@@ -1088,15 +1156,18 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
model = ConsoleServerPort
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'speed')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
(_('Attributes'), ('name', 'label', 'type', 'speed')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=ConsolePortTypeChoices,
required=False
)
speed = forms.MultipleChoiceField(
label=_('Speed'),
choices=ConsolePortSpeedChoices,
required=False
)
@@ -1107,11 +1178,13 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerPort
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerPortTypeChoices,
required=False
)
@@ -1122,11 +1195,13 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = PowerOutlet
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Connection', ('cabled', 'connected', 'occupied')),
(_('Attributes'), ('name', 'label', 'type')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PowerOutletTypeChoices,
required=False
)
@@ -1137,13 +1212,13 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
model = Interface
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
('PoE', ('poe_mode', 'poe_type')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
'device_id', 'vdc_id')),
('Connection', ('cabled', 'connected', 'occupied')),
(_('Attributes'), ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')),
(_('Addressing'), ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')),
(_('PoE'), ('poe_mode', 'poe_type')),
(_('Wireless'), ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
(_('Connection'), ('cabled', 'connected', 'occupied')),
)
vdc_id = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
@@ -1154,30 +1229,36 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
label=_('Virtual Device Context')
)
kind = forms.MultipleChoiceField(
label=_('Kind'),
choices=InterfaceKindChoices,
required=False
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices,
required=False
)
speed = forms.IntegerField(
label=_('Speed'),
required=False,
widget=NumberWithOptions(
options=InterfaceSpeedChoices
)
)
duplex = forms.MultipleChoiceField(
label=_('Duplex'),
choices=InterfaceDuplexChoices,
required=False
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
mgmt_only = forms.NullBooleanField(
label=_('Mgmt only'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
@@ -1185,50 +1266,50 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
)
mac_address = forms.CharField(
required=False,
label='MAC address'
label=_('MAC address')
)
wwn = forms.CharField(
required=False,
label='WWN'
label=_('WWN')
)
poe_mode = forms.MultipleChoiceField(
choices=InterfacePoEModeChoices,
required=False,
label='PoE mode'
label=_('PoE mode')
)
poe_type = forms.MultipleChoiceField(
choices=InterfacePoETypeChoices,
required=False,
label='PoE type'
label=_('PoE type')
)
rf_role = forms.MultipleChoiceField(
choices=WirelessRoleChoices,
required=False,
label='Wireless role'
label=_('Wireless role')
)
rf_channel = forms.MultipleChoiceField(
choices=WirelessChannelChoices,
required=False,
label='Wireless channel'
label=_('Wireless channel')
)
rf_channel_frequency = forms.IntegerField(
required=False,
label='Channel frequency (MHz)'
label=_('Channel frequency (MHz)')
)
rf_channel_width = forms.IntegerField(
required=False,
label='Channel width (MHz)'
label=_('Channel width (MHz)')
)
tx_power = forms.IntegerField(
required=False,
label='Transmit power (dBm)',
label=_('Transmit power (dBm)'),
min_value=0,
max_value=127
)
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF'
label=_('VRF')
)
l2vpn_id = DynamicModelMultipleChoiceField(
queryset=L2VPN.objects.all(),
@@ -1241,16 +1322,19 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
model = FrontPort
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
tag = TagFilterField(model)
@@ -1260,15 +1344,18 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
model = RearPort
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'type', 'color')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
('Cable', ('cabled', 'occupied')),
(_('Attributes'), ('name', 'label', 'type', 'color')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
(_('Cable'), ('cabled', 'occupied')),
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=PortTypeChoices,
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
tag = TagFilterField(model)
@@ -1278,11 +1365,13 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
model = ModuleBay
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'position')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
(_('Attributes'), ('name', 'label', 'position')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
position = forms.CharField(
label=_('Position'),
required=False
)
@@ -1291,8 +1380,9 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
model = DeviceBay
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
(_('Attributes'), ('name', 'label')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
)
tag = TagFilterField(model)
@@ -1301,8 +1391,9 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
model = InventoryItem
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
(_('Attributes'), ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
(_('Device'), ('device_type_id', 'role_id', 'device_id', 'virtual_chassis_id')),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),
@@ -1316,12 +1407,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
label=_('Manufacturer')
)
serial = forms.CharField(
label=_('Serial'),
required=False
)
asset_tag = forms.CharField(
label=_('Asset tag'),
required=False
)
discovered = forms.NullBooleanField(
label=_('Discovered'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES

View File

@@ -1,4 +1,5 @@
from django import forms
from django.utils.translation import gettext_lazy as _
__all__ = (
'BaseVCMemberFormSet',
@@ -16,6 +17,8 @@ class BaseVCMemberFormSet(forms.BaseModelFormSet):
vc_position = form.cleaned_data.get('vc_position')
if vc_position:
if vc_position in vc_position_list:
error_msg = f"A virtual chassis member already exists in position {vc_position}."
error_msg = _("A virtual chassis member already exists in position {vc_position}.").format(
vc_position=vc_position
)
form.add_error('vc_position', error_msg)
vc_position_list.append(vc_position)

View File

@@ -1,7 +1,7 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField
from dcim.choices import *
@@ -70,13 +70,14 @@ __all__ = (
class RegionForm(NetBoxModelForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Region.objects.all(),
required=False
)
slug = SlugField()
fieldsets = (
('Region', (
(_('Region'), (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
@@ -90,13 +91,14 @@ class RegionForm(NetBoxModelForm):
class SiteGroupForm(NetBoxModelForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=SiteGroup.objects.all(),
required=False
)
slug = SlugField()
fieldsets = (
('Site Group', (
(_('Site Group'), (
'parent', 'name', 'slug', 'description', 'tags',
)),
)
@@ -110,10 +112,12 @@ class SiteGroupForm(NetBoxModelForm):
class SiteForm(TenancyForm, NetBoxModelForm):
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False
)
group = DynamicModelChoiceField(
label=_('Group'),
queryset=SiteGroup.objects.all(),
required=False
)
@@ -124,17 +128,18 @@ class SiteForm(TenancyForm, NetBoxModelForm):
)
slug = SlugField()
time_zone = TimeZoneFormField(
label=_('Time zone'),
choices=add_blank_choice(TimeZoneFormField().choices),
required=False
)
comments = CommentField()
fieldsets = (
('Site', (
(_('Site'), (
'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')),
('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')),
(_('Tenancy'), ('tenant_group', 'tenant')),
(_('Contact Info'), ('physical_address', 'shipping_address', 'latitude', 'longitude')),
)
class Meta:
@@ -159,10 +164,12 @@ class SiteForm(TenancyForm, NetBoxModelForm):
class LocationForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
selector=True
)
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Location.objects.all(),
required=False,
query_params={
@@ -172,8 +179,8 @@ class LocationForm(TenancyForm, NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Location', ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
(_('Location'), ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')),
(_('Tenancy'), ('tenant_group', 'tenant')),
)
class Meta:
@@ -187,7 +194,7 @@ class RackRoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Rack Role', (
(_('Rack Role'), (
'name', 'slug', 'color', 'description', 'tags',
)),
)
@@ -201,10 +208,12 @@ class RackRoleForm(NetBoxModelForm):
class RackForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
selector=True
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
query_params={
@@ -212,6 +221,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
}
)
role = DynamicModelChoiceField(
label=_('Role'),
queryset=RackRole.objects.all(),
required=False
)
@@ -221,30 +231,33 @@ class RackForm(TenancyForm, NetBoxModelForm):
model = Rack
fields = [
'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', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
]
class RackReservationForm(TenancyForm, NetBoxModelForm):
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
selector=True
)
units = NumericArrayField(
label=_('Units'),
base_field=forms.IntegerField(),
help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.")
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
label=_('User'),
queryset=get_user_model().objects.order_by(
'username'
)
)
comments = CommentField()
fieldsets = (
('Reservation', ('rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')),
(_('Reservation'), ('rack', 'units', 'user', 'description', 'tags')),
(_('Tenancy'), ('tenant_group', 'tenant')),
)
class Meta:
@@ -258,7 +271,7 @@ class ManufacturerForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Manufacturer', (
(_('Manufacturer'), (
'name', 'slug', 'description', 'tags',
)),
)
@@ -272,23 +285,26 @@ class ManufacturerForm(NetBoxModelForm):
class DeviceTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
default_platform = DynamicModelChoiceField(
label=_('Default platform'),
queryset=Platform.objects.all(),
required=False
)
slug = SlugField(
label=_('Slug'),
slug_source='model'
)
comments = CommentField()
fieldsets = (
('Device Type', ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
('Chassis', (
(_('Device Type'), ('manufacturer', 'model', 'slug', 'default_platform', 'description', 'tags')),
(_('Chassis'), (
'u_height', 'is_full_depth', 'part_number', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
)),
('Images', ('front_image', 'rear_image')),
(_('Images'), ('front_image', 'rear_image')),
)
class Meta:
@@ -310,13 +326,14 @@ class DeviceTypeForm(NetBoxModelForm):
class ModuleTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
comments = CommentField()
fieldsets = (
('Module Type', ('manufacturer', 'model', 'part_number', 'description', 'tags')),
('Weight', ('weight', 'weight_unit'))
(_('Module Type'), ('manufacturer', 'model', 'part_number', 'description', 'tags')),
(_('Weight'), ('weight', 'weight_unit'))
)
class Meta:
@@ -328,13 +345,14 @@ class ModuleTypeForm(NetBoxModelForm):
class DeviceRoleForm(NetBoxModelForm):
config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
required=False
)
slug = SlugField()
fieldsets = (
('Device Role', (
(_('Device Role'), (
'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
)),
)
@@ -348,39 +366,39 @@ class DeviceRoleForm(NetBoxModelForm):
class PlatformForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False
)
config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
required=False
)
slug = SlugField(
label=_('Slug'),
max_length=64
)
fieldsets = (
('Platform', (
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
(_('Platform'), ('name', 'slug', 'manufacturer', 'config_template', 'description', 'tags')),
)
class Meta:
model = Platform
fields = [
'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
]
widgets = {
'napalm_args': forms.Textarea(),
}
class DeviceForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
selector=True
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
query_params={
@@ -391,6 +409,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
}
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
query_params={
@@ -399,6 +418,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
}
)
position = forms.DecimalField(
label=_('Position'),
required=False,
help_text=_("The lowest-numbered unit occupied by the device"),
widget=APISelect(
@@ -410,17 +430,21 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
)
)
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all(),
selector=True
)
device_role = DynamicModelChoiceField(
role = DynamicModelChoiceField(
label=_('Device role'),
queryset=DeviceRole.objects.all()
)
platform = DynamicModelChoiceField(
label=_('Platform'),
queryset=Platform.objects.all(),
required=False
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
selector=True
@@ -431,6 +455,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=''
)
virtual_chassis = DynamicModelChoiceField(
label=_('Virtual chassis'),
queryset=VirtualChassis.objects.all(),
required=False,
selector=True
@@ -446,6 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
help_text=_("The priority of the device in the virtual chassis")
)
config_template = DynamicModelChoiceField(
label=_('Config template'),
queryset=ConfigTemplate.objects.all(),
required=False
)
@@ -453,10 +479,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class Meta:
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
'local_context_data'
'name', 'role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster',
'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template',
'comments', 'tags', 'local_context_data',
]
def __init__(self, *args, **kwargs):
@@ -465,6 +491,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if self.instance.pk:
# Compile list of choices for primary IPv4 and IPv6 addresses
oob_ip_choices = [(None, '---------')]
for family in [4, 6]:
ip_choices = [(None, '---------')]
@@ -480,6 +507,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
if interface_ips:
ip_list = [(ip.id, f'{ip.address} ({ip.assigned_object})') for ip in interface_ips]
ip_choices.append(('Interface IPs', ip_list))
oob_ip_choices.extend(ip_list)
# Collect NAT IPs
nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
address__family=family,
@@ -490,6 +518,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
ip_list = [(ip.id, f'{ip.address} (NAT)') for ip in nat_ips]
ip_choices.append(('NAT IPs', ip_list))
self.fields['primary_ip{}'.format(family)].choices = ip_choices
self.fields['oob_ip'].choices = oob_ip_choices
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
# can be flipped from one face to another.
@@ -509,6 +538,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
self.fields['primary_ip4'].widget.attrs['readonly'] = True
self.fields['primary_ip6'].choices = []
self.fields['primary_ip6'].widget.attrs['readonly'] = True
self.fields['oob_ip'].choices = []
self.fields['oob_ip'].widget.attrs['readonly'] = True
# Rack position
position = self.data.get('position') or self.initial.get('position')
@@ -518,36 +549,41 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class ModuleForm(ModuleCommonForm, NetBoxModelForm):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
initial_params={
'modulebays': '$module_bay'
}
)
module_bay = DynamicModelChoiceField(
label=_('Module bay'),
queryset=ModuleBay.objects.all(),
query_params={
'device_id': '$device'
}
)
module_type = DynamicModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(),
selector=True
)
comments = CommentField()
replicate_components = forms.BooleanField(
label=_('Replicate components'),
required=False,
initial=True,
help_text=_("Automatically populate components associated with this module type")
)
adopt_components = forms.BooleanField(
label=_('Adopt components'),
required=False,
initial=False,
help_text=_("Adopt already existing components")
)
fieldsets = (
('Module', ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')),
('Hardware', (
(_('Module'), ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')),
(_('Hardware'), (
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)),
)
@@ -581,17 +617,19 @@ class CableForm(TenancyForm, NetBoxModelForm):
]
error_messages = {
'length': {
'max_value': 'Maximum length is 32767 (any unit)'
'max_value': _('Maximum length is 32767 (any unit)')
}
}
class PowerPanelForm(NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
selector=True
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
query_params={
@@ -611,12 +649,14 @@ class PowerPanelForm(NetBoxModelForm):
]
class PowerFeedForm(NetBoxModelForm):
class PowerFeedForm(TenancyForm, NetBoxModelForm):
power_panel = DynamicModelChoiceField(
label=_('Power panel'),
queryset=PowerPanel.objects.all(),
selector=True
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
selector=True
@@ -624,15 +664,16 @@ class PowerFeedForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
(_('Power Feed'), ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
(_('Characteristics'), ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
(_('Tenancy'), ('tenant_group', 'tenant')),
)
class Meta:
model = PowerFeed
fields = [
'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'description', 'comments', 'tags',
'max_utilization', 'tenant_group', 'tenant', 'description', 'comments', 'tags'
]
@@ -642,6 +683,7 @@ class PowerFeedForm(NetBoxModelForm):
class VirtualChassisForm(NetBoxModelForm):
master = forms.ModelChoiceField(
label=_('Master'),
queryset=Device.objects.all(),
required=False,
)
@@ -705,6 +747,7 @@ class DeviceVCMembershipForm(forms.ModelForm):
class VCMemberSelectForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
query_params={
'virtual_chassis_id': 'null',
@@ -727,6 +770,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all()
)
@@ -740,10 +784,12 @@ class ComponentTemplateForm(BootstrapMixin, forms.ModelForm):
class ModularComponentTemplateForm(ComponentTemplateForm):
device_type = DynamicModelChoiceField(
label=_('Device type'),
queryset=DeviceType.objects.all().all(),
required=False
)
module_type = DynamicModelChoiceField(
label=_('Module type'),
queryset=ModuleType.objects.all(),
required=False
)
@@ -796,6 +842,7 @@ class PowerPortTemplateForm(ModularComponentTemplateForm):
class PowerOutletTemplateForm(ModularComponentTemplateForm):
power_port = DynamicModelChoiceField(
label=_('Power port'),
queryset=PowerPortTemplate.objects.all(),
required=False,
query_params={
@@ -816,6 +863,7 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
class InterfaceTemplateForm(ModularComponentTemplateForm):
bridge = DynamicModelChoiceField(
label=_('Bridge'),
queryset=InterfaceTemplate.objects.all(),
required=False,
query_params={
@@ -826,18 +874,20 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
fieldsets = (
(None, ('device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge')),
('PoE', ('poe_mode', 'poe_type'))
(_('PoE'), ('poe_mode', 'poe_type')),
(_('Wireless'), ('rf_role',)),
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge',
'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode', 'poe_type', 'bridge', 'rf_role',
]
class FrontPortTemplateForm(ModularComponentTemplateForm):
rear_port = DynamicModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
required=False,
query_params={
@@ -899,6 +949,7 @@ class DeviceBayTemplateForm(ComponentTemplateForm):
class InventoryItemTemplateForm(ComponentTemplateForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=InventoryItemTemplate.objects.all(),
required=False,
query_params={
@@ -906,10 +957,12 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
}
)
role = DynamicModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(),
required=False
)
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False
)
@@ -945,6 +998,7 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
class DeviceComponentForm(NetBoxModelForm):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
selector=True
)
@@ -959,6 +1013,7 @@ class DeviceComponentForm(NetBoxModelForm):
class ModularDeviceComponentForm(DeviceComponentForm):
module = DynamicModelChoiceField(
label=_('Module'),
queryset=Module.objects.all(),
required=False,
query_params={
@@ -1015,6 +1070,7 @@ class PowerPortForm(ModularDeviceComponentForm):
class PowerOutletForm(ModularDeviceComponentForm):
power_port = DynamicModelChoiceField(
label=_('Power port'),
queryset=PowerPort.objects.all(),
required=False,
query_params={
@@ -1041,7 +1097,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
vdcs = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
label='Virtual Device Contexts',
label=_('Virtual device contexts'),
query_params={
'device_id': '$device',
}
@@ -1119,13 +1175,13 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
)
fieldsets = (
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
('Addressing', ('vrf', 'mac_address', 'wwn')),
('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')),
('Wireless', (
(_('Interface'), ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
(_('Addressing'), ('vrf', 'mac_address', 'wwn')),
(_('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')),
(_('Wireless'), (
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
)),
)
@@ -1231,6 +1287,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class InventoryItemForm(DeviceComponentForm):
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=InventoryItem.objects.all(),
required=False,
query_params={
@@ -1238,10 +1295,12 @@ class InventoryItemForm(DeviceComponentForm):
}
)
role = DynamicModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(),
required=False
)
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False
)
@@ -1305,8 +1364,8 @@ class InventoryItemForm(DeviceComponentForm):
)
fieldsets = (
('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
(_('Inventory Item'), ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
(_('Hardware'), ('manufacturer', 'part_id', 'serial', 'asset_tag')),
)
class Meta:
@@ -1357,7 +1416,7 @@ class InventoryItemForm(DeviceComponentForm):
) if self.cleaned_data[field]
]
if len(selected_objects) > 1:
raise forms.ValidationError("An InventoryItem can only be assigned to a single component.")
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:
@@ -1371,7 +1430,7 @@ class InventoryItemRoleForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
('Inventory Item Role', (
(_('Inventory Item Role'), (
'name', 'slug', 'color', 'description', 'tags',
)),
)
@@ -1385,12 +1444,13 @@ class InventoryItemRoleForm(NetBoxModelForm):
class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
selector=True
)
primary_ip4 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv4',
label=_('Primary IPv4'),
required=False,
query_params={
'device_id': '$device',
@@ -1399,7 +1459,7 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
)
primary_ip6 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
label='Primary IPv6',
label=_('Primary IPv6'),
required=False,
query_params={
'device_id': '$device',
@@ -1408,8 +1468,8 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
)
fieldsets = (
('Virtual Device Context', ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
('Tenancy', ('tenant_group', 'tenant'))
(_('Virtual Device Context'), ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
(_('Tenancy'), ('tenant_group', 'tenant'))
)
class Meta:

View File

@@ -1,5 +1,5 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from dcim.models import *
from netbox.forms import NetBoxModelForm
@@ -38,8 +38,11 @@ class ComponentCreateForm(forms.Form):
Subclass this form when facilitating the creation of one or more component or component template objects based on
a name pattern.
"""
name = ExpandableNameField()
name = ExpandableNameField(
label=_('Name'),
)
label = ExpandableNameField(
label=_('Label'),
required=False,
help_text=_('Alphanumeric ranges are supported. (Must match the number of objects being created.)')
)
@@ -57,8 +60,9 @@ class ComponentCreateForm(forms.Form):
value_count = len(self.cleaned_data[field_name])
if self.cleaned_data[field_name] and value_count != pattern_count:
raise forms.ValidationError({
field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are '
f'expected.'
field_name: _(
"The provided pattern specifies {value_count} values, but {pattern_count} are expected."
).format(value_count=value_count, pattern_count=pattern_count)
}, code='label_pattern_mismatch')
@@ -101,6 +105,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
choices=[],
label=_('Rear ports'),
help_text=_('Select one rear port assignment for each front port being created.'),
widget=forms.SelectMultiple(attrs={'size': 6})
)
# Override fieldsets from FrontPortTemplateForm to omit rear_port_position
@@ -221,12 +226,14 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
super().__init__(*args, **kwargs)
if 'module' in self.fields:
self.fields['name'].help_text += ' The string <code>{module}</code> will be replaced with the position ' \
'of the assigned module, if any'
self.fields['name'].help_text += _(
"The string <code>{module}</code> will be replaced with the position of the assigned module, if any."
)
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
selector=True,
widget=APISelect(
@@ -328,6 +335,7 @@ class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm
class VirtualChassisCreateForm(NetBoxModelForm):
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False,
initial_params={
@@ -335,6 +343,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
site_group = DynamicModelChoiceField(
label=_('Site group'),
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
@@ -342,6 +351,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
query_params={
@@ -350,6 +360,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
null_option='None',
@@ -358,6 +369,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
members = DynamicModelMultipleChoiceField(
label=_('Members'),
queryset=Device.objects.all(),
required=False,
query_params={
@@ -366,6 +378,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
}
)
initial_position = forms.IntegerField(
label=_('Initial position'),
initial=1,
required=False,
help_text=_('Position of the first member device. Increases by one for each additional member.')
@@ -382,7 +395,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
raise forms.ValidationError({
'initial_position': "A position must be specified for the first VC member."
'initial_position': _("A position must be specified for the first VC member.")
})
def save(self, *args, **kwargs):

View File

@@ -1,9 +1,10 @@
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices
from dcim.models import *
from utilities.forms import BootstrapMixin
from wireless.choices import WirelessRoleChoices
__all__ = (
'ConsolePortTemplateImportForm',
@@ -56,6 +57,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
power_port = forms.ModelChoiceField(
label=_('Power port'),
queryset=PowerPortTemplate.objects.all(),
to_field_name='name',
required=False
@@ -84,6 +86,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class InterfaceTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
label=_('Type'),
choices=InterfaceTypeChoices.CHOICES
)
poe_mode = forms.ChoiceField(
@@ -96,19 +99,27 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
required=False,
label=_('PoE type')
)
rf_role = forms.ChoiceField(
choices=WirelessRoleChoices,
required=False,
label=_('Wireless role')
)
class Meta:
model = InterfaceTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
'poe_type', 'rf_role'
]
class FrontPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
label=_('Type'),
choices=PortTypeChoices.CHOICES
)
rear_port = forms.ModelChoiceField(
label=_('Rear port'),
queryset=RearPortTemplate.objects.all(),
to_field_name='name'
)
@@ -136,6 +147,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class RearPortTemplateImportForm(ComponentTemplateImportForm):
type = forms.ChoiceField(
label=_('Type'),
choices=PortTypeChoices.CHOICES
)
@@ -166,15 +178,18 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class InventoryItemTemplateImportForm(ComponentTemplateImportForm):
parent = forms.ModelChoiceField(
label=_('Parent'),
queryset=InventoryItemTemplate.objects.all(),
required=False
)
role = forms.ModelChoiceField(
label=_('Role'),
queryset=InventoryItemRole.objects.all(),
to_field_name='name',
required=False
)
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
required=False

View File

@@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination:
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) == ConsolePortType:
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerFeed:
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType
@@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination:
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) == ConsolePortType:
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerFeed:
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType
@@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == ConsolePortTemplate:
if type(instance) is ConsolePortTemplate:
return ConsolePortTemplateType
if type(instance) == ConsoleServerPortTemplate:
if type(instance) is ConsoleServerPortTemplate:
return ConsoleServerPortTemplateType
if type(instance) == FrontPortTemplate:
if type(instance) is FrontPortTemplate:
return FrontPortTemplateType
if type(instance) == InterfaceTemplate:
if type(instance) is InterfaceTemplate:
return InterfaceTemplateType
if type(instance) == PowerOutletTemplate:
if type(instance) is PowerOutletTemplate:
return PowerOutletTemplateType
if type(instance) == PowerPortTemplate:
if type(instance) is PowerPortTemplate:
return PowerPortTemplateType
if type(instance) == RearPortTemplate:
if type(instance) is RearPortTemplate:
return RearPortTemplateType
@@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == ConsolePort:
if type(instance) is ConsolePort:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType

View File

@@ -277,6 +277,9 @@ class InterfaceTemplateType(ComponentTemplateObjectType):
def resolve_poe_type(self, info):
return self.poe_type or None
def resolve_rf_role(self, info):
return self.rf_role or None
class InventoryItemType(ComponentObjectType):
component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType')

View File

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

View File

@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0172_larger_power_draw_values'),
]
operations = [
migrations.RemoveField(
model_name='platform',
name='napalm_args',
),
migrations.RemoveField(
model_name='platform',
name='napalm_driver',
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.1.9 on 2023-05-31 22:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0173_remove_napalm_fields'),
]
operations = [
migrations.AddField(
model_name='device',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True),
),
migrations.AddField(
model_name='device',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.1.9 on 2023-05-31 15:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0174_device_latitude_device_longitude'),
]
operations = [
migrations.AddField(
model_name='rack',
name='starting_unit',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 4.1.9 on 2023-07-24 20:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ipam', '0066_iprange_mark_utilized'),
('dcim', '0174_rack_starting_unit'),
]
operations = [
migrations.AddField(
model_name='device',
name='oob_ip',
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='+',
to='ipam.ipaddress',
),
),
]

View File

@@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_device_counts(apps, schema_editor):
Device = apps.get_model("dcim", "Device")
devices = list(Device.objects.all().annotate(
_console_port_count=Count('consoleports', distinct=True),
_console_server_port_count=Count('consoleserverports', distinct=True),
_power_port_count=Count('powerports', distinct=True),
_power_outlet_count=Count('poweroutlets', distinct=True),
_interface_count=Count('interfaces', distinct=True),
_front_port_count=Count('frontports', distinct=True),
_rear_port_count=Count('rearports', distinct=True),
_device_bay_count=Count('devicebays', distinct=True),
_module_bay_count=Count('modulebays', distinct=True),
_inventory_item_count=Count('inventoryitems', distinct=True),
))
for device in devices:
device.console_port_count = device._console_port_count
device.console_server_port_count = device._console_server_port_count
device.power_port_count = device._power_port_count
device.power_outlet_count = device._power_outlet_count
device.interface_count = device._interface_count
device.front_port_count = device._front_port_count
device.rear_port_count = device._rear_port_count
device.device_bay_count = device._device_bay_count
device.module_bay_count = device._module_bay_count
device.inventory_item_count = device._inventory_item_count
Device.objects.bulk_update(devices, [
'console_port_count',
'console_server_port_count',
'power_port_count',
'power_outlet_count',
'interface_count',
'front_port_count',
'rear_port_count',
'device_bay_count',
'module_bay_count',
'inventory_item_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0175_device_oob_ip'),
]
operations = [
migrations.AddField(
model_name='device',
name='console_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsolePort'),
),
migrations.AddField(
model_name='device',
name='console_server_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ConsoleServerPort'),
),
migrations.AddField(
model_name='device',
name='power_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerPort'),
),
migrations.AddField(
model_name='device',
name='power_outlet_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.PowerOutlet'),
),
migrations.AddField(
model_name='device',
name='interface_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.Interface'),
),
migrations.AddField(
model_name='device',
name='front_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.FrontPort'),
),
migrations.AddField(
model_name='device',
name='rear_port_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.RearPort'),
),
migrations.AddField(
model_name='device',
name='device_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.DeviceBay'),
),
migrations.AddField(
model_name='device',
name='module_bay_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.ModuleBay'),
),
migrations.AddField(
model_name='device',
name='inventory_item_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device', to_model='dcim.InventoryItem'),
),
migrations.RunPython(
recalculate_device_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,108 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def recalculate_devicetype_template_counts(apps, schema_editor):
DeviceType = apps.get_model("dcim", "DeviceType")
device_types = list(DeviceType.objects.all().annotate(
_console_port_template_count=Count('consoleporttemplates', distinct=True),
_console_server_port_template_count=Count('consoleserverporttemplates', distinct=True),
_power_port_template_count=Count('powerporttemplates', distinct=True),
_power_outlet_template_count=Count('poweroutlettemplates', distinct=True),
_interface_template_count=Count('interfacetemplates', distinct=True),
_front_port_template_count=Count('frontporttemplates', distinct=True),
_rear_port_template_count=Count('rearporttemplates', distinct=True),
_device_bay_template_count=Count('devicebaytemplates', distinct=True),
_module_bay_template_count=Count('modulebaytemplates', distinct=True),
_inventory_item_template_count=Count('inventoryitemtemplates', distinct=True),
))
for devicetype in device_types:
devicetype.console_port_template_count = devicetype._console_port_template_count
devicetype.console_server_port_template_count = devicetype._console_server_port_template_count
devicetype.power_port_template_count = devicetype._power_port_template_count
devicetype.power_outlet_template_count = devicetype._power_outlet_template_count
devicetype.interface_template_count = devicetype._interface_template_count
devicetype.front_port_template_count = devicetype._front_port_template_count
devicetype.rear_port_template_count = devicetype._rear_port_template_count
devicetype.device_bay_template_count = devicetype._device_bay_template_count
devicetype.module_bay_template_count = devicetype._module_bay_template_count
devicetype.inventory_item_template_count = devicetype._inventory_item_template_count
DeviceType.objects.bulk_update(device_types, [
'console_port_template_count',
'console_server_port_template_count',
'power_port_template_count',
'power_outlet_template_count',
'interface_template_count',
'front_port_template_count',
'rear_port_template_count',
'device_bay_template_count',
'module_bay_template_count',
'inventory_item_template_count',
])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0176_device_component_counters'),
]
operations = [
migrations.AddField(
model_name='devicetype',
name='console_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsolePortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='console_server_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ConsoleServerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='power_outlet_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.PowerOutletTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='interface_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InterfaceTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='front_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.FrontPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='rear_port_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.RearPortTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='device_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.DeviceBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='module_bay_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.ModuleBayTemplate'),
),
migrations.AddField(
model_name='devicetype',
name='inventory_item_template_count',
field=utilities.fields.CounterCacheField(default=0, to_field='device_type', to_model='dcim.InventoryItemTemplate'),
),
migrations.RunPython(
recalculate_devicetype_template_counts,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Count
import utilities.fields
def populate_virtualchassis_members(apps, schema_editor):
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
vcs = list(VirtualChassis.objects.annotate(_member_count=Count('members', distinct=True)))
for vc in vcs:
vc.member_count = vc._member_count
VirtualChassis.objects.bulk_update(vcs, ['member_count'])
class Migration(migrations.Migration):
dependencies = [
('dcim', '0177_devicetype_component_counters'),
]
operations = [
migrations.AddField(
model_name='virtualchassis',
name='member_count',
field=utilities.fields.CounterCacheField(
default=0, to_field='virtual_chassis', to_model='dcim.Device'
),
),
migrations.RunPython(
code=populate_virtualchassis_members,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.2 on 2023-07-18 07:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0178_virtual_chassis_member_counter'),
]
operations = [
migrations.AddField(
model_name='interfacetemplate',
name='rf_role',
field=models.CharField(blank=True, max_length=30),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.1.8 on 2023-07-29 11:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0010_tenant_relax_uniqueness'),
('dcim', '0179_interfacetemplate_rf_role'),
]
operations = [
migrations.AddField(
model_name='powerfeed',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'),
),
]

View File

@@ -0,0 +1,35 @@
from django.db import migrations
def update_table_configs(apps, schema_editor):
"""
Replace the `device_role` column in DeviceTable configs with `role`.
"""
UserConfig = apps.get_model('users', 'UserConfig')
for table in ('DeviceTable', 'DeviceBayTable'):
for config in UserConfig.objects.filter(**{f'data__tables__{table}__columns__contains': 'device_role'}):
config.data['tables'][table]['columns'] = [
'role' if x == 'device_role' else x
for x in config.data['tables'][table]['columns']
]
config.save()
class Migration(migrations.Migration):
dependencies = [
('dcim', '0180_powerfeed_tenant'),
]
operations = [
migrations.RenameField(
model_name='device',
old_name='device_role',
new_name='role',
),
migrations.RunPython(
code=update_table_configs,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -8,6 +8,7 @@ from django.db import models
from django.db.models import Sum
from django.dispatch import Signal
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -40,11 +41,13 @@ class Cable(PrimaryModel):
A physical connection between two endpoints.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=CableTypeChoices,
blank=True
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=LinkStatusChoices,
default=LinkStatusChoices.STATUS_CONNECTED
@@ -57,19 +60,23 @@ class Cable(PrimaryModel):
null=True
)
label = models.CharField(
verbose_name=_('label'),
max_length=100,
blank=True
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
length = models.DecimalField(
verbose_name=_('length'),
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
length_unit = models.CharField(
verbose_name=_('length unit'),
max_length=50,
choices=CableLengthUnitChoices,
blank=True,
@@ -235,7 +242,7 @@ class CableTermination(ChangeLoggedModel):
cable_end = models.CharField(
max_length=1,
choices=CableEndChoices,
verbose_name='End'
verbose_name=_('end')
)
termination_type = models.ForeignKey(
to=ContentType,
@@ -359,6 +366,7 @@ class CableTermination(ChangeLoggedModel):
# Circuit terminations
elif getattr(self.termination, 'site', None):
self._site = self.termination.site
cache_related_objects.alters_data = True
def to_objectchange(self, action):
objectchange = super().to_objectchange(action)
@@ -402,15 +410,19 @@ class CablePath(models.Model):
`_nodes` retains a flattened list of all nodes within the path to enable simple filtering.
"""
path = models.JSONField(
verbose_name=_('path'),
default=list
)
is_active = models.BooleanField(
verbose_name=_('is active'),
default=False
)
is_complete = models.BooleanField(
verbose_name=_('is complete'),
default=False
)
is_split = models.BooleanField(
verbose_name=_('is split'),
default=False
)
_nodes = PathField()
@@ -637,6 +649,7 @@ class CablePath(models.Model):
self.save()
else:
self.delete()
retrace.alters_data = True
def _get_path(self):
"""

View File

@@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -12,6 +12,8 @@ from netbox.models import ChangeLoggedModel
from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.tracking import TrackingModelMixin
from wireless.choices import WirelessRoleChoices
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
RearPort,
@@ -32,17 +34,18 @@ __all__ = (
)
class ComponentTemplateModel(ChangeLoggedModel):
class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='%(class)ss'
)
name = models.CharField(
verbose_name=_('name'),
max_length=64,
help_text="""
{module} is accepted as a substitution for the module bay position when attached to a module type.
"""
help_text=_(
"{module} is accepted as a substitution for the module bay position when attached to a module type."
)
)
_name = NaturalOrderingField(
target_field='name',
@@ -50,11 +53,13 @@ class ComponentTemplateModel(ChangeLoggedModel):
blank=True
)
label = models.CharField(
verbose_name=_('label'),
max_length=64,
blank=True,
help_text=_("Physical label")
help_text=_('Physical label')
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
@@ -96,7 +101,7 @@ class ComponentTemplateModel(ChangeLoggedModel):
if self.pk is not None and self._original_device_type != self.device_type_id:
raise ValidationError({
"device_type": "Component templates cannot be moved to a different device type."
"device_type": _("Component templates cannot be moved to a different device type.")
})
@@ -147,11 +152,11 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
# A component template must belong to a DeviceType *or* to a ModuleType
if self.device_type and self.module_type:
raise ValidationError(
"A component template cannot be associated with both a device type and a module type."
_("A component template cannot be associated with both a device type and a module type.")
)
if not self.device_type and not self.module_type:
raise ValidationError(
"A component template must be associated with either a device type or a module type."
_("A component template must be associated with either a device type or a module type.")
)
def resolve_name(self, module):
@@ -170,6 +175,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
A template for a ConsolePort to be created for a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
@@ -199,6 +205,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
A template for a ConsoleServerPort to be created for a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
@@ -213,6 +220,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
type=self.type,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -228,21 +236,24 @@ class PowerPortTemplate(ModularComponentTemplateModel):
A template for a PowerPort to be created for a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PowerPortTypeChoices,
blank=True
)
maximum_draw = models.PositiveIntegerField(
verbose_name=_('maximum draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
help_text=_('Maximum power draw (watts)')
)
allocated_draw = models.PositiveIntegerField(
verbose_name=_('allocated draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Allocated power draw (watts)")
help_text=_('Allocated power draw (watts)')
)
component_model = PowerPort
@@ -256,6 +267,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
allocated_draw=self.allocated_draw,
**kwargs
)
instantiate.do_not_call_in_templates = True
def clean(self):
super().clean()
@@ -263,7 +275,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
'allocated_draw': _("Allocated draw cannot exceed the maximum draw ({maximum_draw}W).").format(maximum_draw=self.maximum_draw)
})
def to_yaml(self):
@@ -282,6 +294,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
A template for a PowerOutlet to be created for a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PowerOutletTypeChoices,
blank=True
@@ -294,10 +307,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
related_name='poweroutlet_templates'
)
feed_leg = models.CharField(
verbose_name=_('feed leg'),
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
help_text=_("Phase (for three-phase feeds)")
help_text=_('Phase (for three-phase feeds)')
)
component_model = PowerOutlet
@@ -309,11 +323,11 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
if self.power_port:
if self.device_type and self.power_port.device_type != self.device_type:
raise ValidationError(
f"Parent power port ({self.power_port}) must belong to the same device type"
_("Parent power port ({power_port}) must belong to the same device type").format(power_port=self.power_port)
)
if self.module_type and self.power_port.module_type != self.module_type:
raise ValidationError(
f"Parent power port ({self.power_port}) must belong to the same module type"
_("Parent power port ({power_port}) must belong to the same module type").format(power_port=self.power_port)
)
def instantiate(self, **kwargs):
@@ -330,6 +344,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
feed_leg=self.feed_leg,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -354,15 +369,17 @@ class InterfaceTemplate(ModularComponentTemplateModel):
blank=True
)
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=InterfaceTypeChoices
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='Management only'
verbose_name=_('management only')
)
bridge = models.ForeignKey(
to='self',
@@ -370,19 +387,25 @@ class InterfaceTemplate(ModularComponentTemplateModel):
related_name='bridge_interfaces',
null=True,
blank=True,
verbose_name='Bridge interface'
verbose_name=_('bridge interface')
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
verbose_name=_('PoE mode')
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
verbose_name=_('PoE type')
)
rf_role = models.CharField(
max_length=30,
choices=WirelessRoleChoices,
blank=True,
verbose_name=_('wireless role')
)
component_model = Interface
@@ -392,16 +415,21 @@ class InterfaceTemplate(ModularComponentTemplateModel):
if self.bridge:
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
if self.device_type and self.device_type != self.bridge.device_type:
raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same device type"
'bridge': _("Bridge interface ({bridge}) must belong to the same device type").format(bridge=self.bridge)
})
if self.module_type and self.module_type != self.bridge.module_type:
raise ValidationError({
'bridge': f"Bridge interface ({self.bridge}) must belong to the same module type"
'bridge': _("Bridge interface ({bridge}) must belong to the same module type").format(bridge=self.bridge)
})
if self.rf_role and self.type not in WIRELESS_IFACE_TYPES:
raise ValidationError({
'rf_role': "Wireless role may be set only on wireless interfaces."
})
def instantiate(self, **kwargs):
return self.component_model(
name=self.resolve_name(kwargs.get('module')),
@@ -411,8 +439,10 @@ class InterfaceTemplate(ModularComponentTemplateModel):
mgmt_only=self.mgmt_only,
poe_mode=self.poe_mode,
poe_type=self.poe_type,
rf_role=self.rf_role,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -425,6 +455,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
'bridge': self.bridge.name if self.bridge else None,
'poe_mode': self.poe_mode,
'poe_type': self.poe_type,
'rf_role': self.rf_role,
}
@@ -433,10 +464,12 @@ class FrontPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the front of a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
@@ -445,6 +478,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
related_name='frontport_templates'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -478,13 +512,13 @@ class FrontPortTemplate(ModularComponentTemplateModel):
# Validate rear port assignment
if self.rear_port.device_type != self.device_type:
raise ValidationError(
"Rear port ({}) must belong to the same device type".format(self.rear_port)
_("Rear port ({}) must belong to the same device type").format(self.rear_port)
)
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError(
"Invalid rear port position ({}); rear port {} has only {} positions".format(
_("Invalid rear port position ({}); rear port {} has only {} positions").format(
self.rear_port_position, self.rear_port.name, self.rear_port.positions
)
)
@@ -507,6 +541,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
rear_port_position=self.rear_port_position,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -525,13 +560,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
Template for a pass-through port on the rear of a new Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -550,6 +588,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
positions=self.positions,
**kwargs
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -567,6 +606,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
A template for a ModuleBay to be created for a new parent Device.
"""
position = models.CharField(
verbose_name=_('position'),
max_length=30,
blank=True,
help_text=_('Identifier to reference when renaming installed components')
@@ -581,6 +621,7 @@ class ModuleBayTemplate(ComponentTemplateModel):
label=self.label,
position=self.position
)
instantiate.do_not_call_in_templates = True
def to_yaml(self):
return {
@@ -603,11 +644,12 @@ class DeviceBayTemplate(ComponentTemplateModel):
name=self.name,
label=self.label
)
instantiate.do_not_call_in_templates = True
def clean(self):
if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT:
raise ValidationError(
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
_("Subdevice role of device type ({device_type}) must be set to \"parent\" to allow device bays.").format(device_type=self.device_type)
)
def to_yaml(self):
@@ -662,7 +704,7 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
)
part_id = models.CharField(
max_length=50,
verbose_name='Part ID',
verbose_name=_('part ID'),
blank=True,
help_text=_('Manufacturer-assigned part identifier')
)
@@ -696,3 +738,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
part_id=self.part_id,
**kwargs
)
instantiate.do_not_call_in_templates = True

View File

@@ -7,7 +7,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Sum
from django.urls import reverse
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import *
@@ -19,6 +19,7 @@ from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface
from utilities.query_functions import CollateAsChar
from utilities.tracking import TrackingModelMixin
from wireless.choices import *
from wireless.utils import get_channel_attr
@@ -51,6 +52,7 @@ class ComponentModel(NetBoxModel):
related_name='%(class)ss'
)
name = models.CharField(
verbose_name=_('name'),
max_length=64
)
_name = NaturalOrderingField(
@@ -59,11 +61,13 @@ class ComponentModel(NetBoxModel):
blank=True
)
label = models.CharField(
verbose_name=_('label'),
max_length=64,
blank=True,
help_text=_("Physical label")
help_text=_('Physical label')
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
@@ -100,7 +104,7 @@ class ComponentModel(NetBoxModel):
# Check list of Modules that allow device field to be changed
if (type(self) not in [InventoryItem]) and (self.pk is not None) and (self._original_device != self.device_id):
raise ValidationError({
"device": "Components cannot be moved to a different device."
"device": _("Components cannot be moved to a different device.")
})
@property
@@ -139,13 +143,15 @@ class CabledObjectModel(models.Model):
null=True
)
cable_end = models.CharField(
verbose_name=_('cable end'),
max_length=1,
blank=True,
choices=CableEndChoices
)
mark_connected = models.BooleanField(
verbose_name=_('mark connected'),
default=False,
help_text=_("Treat as if a cable is connected")
help_text=_('Treat as if a cable is connected')
)
cable_terminations = GenericRelation(
@@ -163,15 +169,15 @@ class CabledObjectModel(models.Model):
if self.cable and not self.cable_end:
raise ValidationError({
"cable_end": "Must specify cable end (A or B) when attaching a cable."
"cable_end": _("Must specify cable end (A or B) when attaching a cable.")
})
if self.cable_end and not self.cable:
raise ValidationError({
"cable_end": "Cable end must not be set without a cable."
"cable_end": _("Cable end must not be set without a cable.")
})
if self.mark_connected and self.cable:
raise ValidationError({
"mark_connected": "Cannot mark as connected with a cable attached."
"mark_connected": _("Cannot mark as connected with a cable attached.")
})
@property
@@ -194,7 +200,9 @@ class CabledObjectModel(models.Model):
@property
def parent_object(self):
raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property")
raise NotImplementedError(
_("{class_name} models must declare a parent_object property").format(class_name=self.__class__.__name__)
)
@property
def opposite_cable_end(self):
@@ -269,17 +277,19 @@ class PathEndpoint(models.Model):
# Console components
#
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
verbose_name=_('speed'),
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
@@ -292,17 +302,19 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
speed = models.PositiveIntegerField(
verbose_name=_('speed'),
choices=ConsolePortSpeedChoices,
blank=True,
null=True,
@@ -319,27 +331,30 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Power components
#
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PowerPortTypeChoices,
blank=True,
help_text=_('Physical port type')
)
maximum_draw = models.PositiveIntegerField(
verbose_name=_('maximum draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Maximum power draw (watts)")
)
allocated_draw = models.PositiveIntegerField(
verbose_name=_('allocated draw'),
blank=True,
null=True,
validators=[MinValueValidator(1)],
help_text=_("Allocated power draw (watts)")
help_text=_('Allocated power draw (watts)')
)
clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw')
@@ -353,7 +368,9 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
if self.maximum_draw is not None and self.allocated_draw is not None:
if self.allocated_draw > self.maximum_draw:
raise ValidationError({
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
'allocated_draw': _(
"Allocated draw cannot exceed the maximum draw ({maximum_draw}W)."
).format(maximum_draw=self.maximum_draw)
})
def get_downstream_powerports(self, leg=None):
@@ -428,11 +445,12 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint):
}
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PowerOutletTypeChoices,
blank=True,
@@ -446,10 +464,11 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
related_name='poweroutlets'
)
feed_leg = models.CharField(
verbose_name=_('feed leg'),
max_length=50,
choices=PowerOutletFeedLegChoices,
blank=True,
help_text=_("Phase (for three-phase feeds)")
help_text=_('Phase (for three-phase feeds)')
)
clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg')
@@ -462,7 +481,9 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint):
# Validate power port assignment
if self.power_port and self.power_port.device != self.device:
raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device")
raise ValidationError(
_("Parent power port ({power_port}) must belong to the same device").format(power_port=self.power_port)
)
#
@@ -474,12 +495,13 @@ class BaseInterface(models.Model):
Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
"""
enabled = models.BooleanField(
verbose_name=_('enabled'),
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
verbose_name=_('MAC address')
)
mtu = models.PositiveIntegerField(
blank=True,
@@ -488,13 +510,14 @@ class BaseInterface(models.Model):
MinValueValidator(INTERFACE_MTU_MIN),
MaxValueValidator(INTERFACE_MTU_MAX)
],
verbose_name='MTU'
verbose_name=_('MTU')
)
mode = models.CharField(
verbose_name=_('mode'),
max_length=50,
choices=InterfaceModeChoices,
blank=True,
help_text=_("IEEE 802.1Q tagging strategy")
help_text=_('IEEE 802.1Q tagging strategy')
)
parent = models.ForeignKey(
to='self',
@@ -502,7 +525,7 @@ class BaseInterface(models.Model):
related_name='child_interfaces',
null=True,
blank=True,
verbose_name='Parent interface'
verbose_name=_('parent interface')
)
bridge = models.ForeignKey(
to='self',
@@ -510,7 +533,7 @@ class BaseInterface(models.Model):
related_name='bridge_interfaces',
null=True,
blank=True,
verbose_name='Bridge interface'
verbose_name=_('bridge interface')
)
class Meta:
@@ -537,7 +560,7 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count()
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint):
class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint, TrackingModelMixin):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -558,23 +581,25 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='member_interfaces',
null=True,
blank=True,
verbose_name='Parent LAG'
verbose_name=_('parent LAG')
)
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=InterfaceTypeChoices
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='Management only',
verbose_name=_('management only'),
help_text=_('This interface is used only for out-of-band management')
)
speed = models.PositiveIntegerField(
blank=True,
null=True,
verbose_name='Speed (Kbps)'
verbose_name=_('speed (Kbps)')
)
duplex = models.CharField(
verbose_name=_('duplex'),
max_length=50,
blank=True,
null=True,
@@ -583,27 +608,27 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
wwn = WWNField(
null=True,
blank=True,
verbose_name='WWN',
verbose_name=_('WWN'),
help_text=_('64-bit World Wide Name')
)
rf_role = models.CharField(
max_length=30,
choices=WirelessRoleChoices,
blank=True,
verbose_name='Wireless role'
verbose_name=_('wireless role')
)
rf_channel = models.CharField(
max_length=50,
choices=WirelessChannelChoices,
blank=True,
verbose_name='Wireless channel'
verbose_name=_('wireless channel')
)
rf_channel_frequency = models.DecimalField(
max_digits=7,
decimal_places=2,
blank=True,
null=True,
verbose_name='Channel frequency (MHz)',
verbose_name=_('channel frequency (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
rf_channel_width = models.DecimalField(
@@ -611,26 +636,26 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
decimal_places=3,
blank=True,
null=True,
verbose_name='Channel width (MHz)',
verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)")
)
tx_power = models.PositiveSmallIntegerField(
blank=True,
null=True,
validators=(MaxValueValidator(127),),
verbose_name='Transmit power (dBm)'
verbose_name=_('transmit power (dBm)')
)
poe_mode = models.CharField(
max_length=50,
choices=InterfacePoEModeChoices,
blank=True,
verbose_name='PoE mode'
verbose_name=_('PoE mode')
)
poe_type = models.CharField(
max_length=50,
choices=InterfacePoETypeChoices,
blank=True,
verbose_name='PoE type'
verbose_name=_('PoE type')
)
wireless_link = models.ForeignKey(
to='wireless.WirelessLink',
@@ -643,7 +668,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
to='wireless.WirelessLAN',
related_name='interfaces',
blank=True,
verbose_name='Wireless LANs'
verbose_name=_('wireless LANs')
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
@@ -651,13 +676,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces_as_untagged',
null=True,
blank=True,
verbose_name='Untagged VLAN'
verbose_name=_('untagged VLAN')
)
tagged_vlans = models.ManyToManyField(
to='ipam.VLAN',
related_name='interfaces_as_tagged',
blank=True,
verbose_name='Tagged VLANs'
verbose_name=_('tagged VLANs')
)
vrf = models.ForeignKey(
to='ipam.VRF',
@@ -665,7 +690,7 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
related_name='interfaces',
null=True,
blank=True,
verbose_name='VRF'
verbose_name=_('VRF')
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
@@ -703,77 +728,98 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Virtual Interfaces cannot have a Cable attached
if self.is_virtual and self.cable:
raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
'type': _("{display_type} interfaces cannot have a cable attached.").format(
display_type=self.get_type_display()
)
})
# Virtual Interfaces cannot be marked as connected
if self.is_virtual and self.mark_connected:
raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
'mark_connected': _("{display_type} interfaces cannot be marked as connected.".format(
display_type=self.get_type_display())
)
})
# Parent validation
# An interface cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({'parent': "An interface cannot be its own parent."})
raise ValidationError({'parent': _("An interface cannot be its own parent.")})
# A physical interface cannot have a parent interface
if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."})
raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
# An interface's parent must belong to the same device or virtual chassis
if self.parent and self.parent.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to a different device "
f"({self.parent.device})."
'parent': _(
"The selected parent interface ({interface}) belongs to a different device ({device})"
).format(interface=self.parent, device=self.parent.device)
})
elif self.parent.device.virtual_chassis != self.parent.virtual_chassis:
raise ValidationError({
'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which "
f"is not part of virtual chassis {self.device.virtual_chassis}."
'parent': _(
"The selected parent interface ({interface}) belongs to {device}, which is not part of "
"virtual chassis {virtual_chassis}."
).format(
interface=self.parent,
device=self.parent_device,
virtual_chassis=self.device.virtual_chassis
)
})
# Bridge validation
# An interface cannot be bridged to itself
if self.pk and self.bridge_id == self.pk:
raise ValidationError({'bridge': "An interface cannot be bridged to itself."})
raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})
# A bridged interface belong to the same device or virtual chassis
if self.bridge and self.bridge.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device "
f"({self.bridge.device})."
'bridge': _("""
The selected bridge interface ({bridge}) belongs to a different device
({device}).""").format(bridge=self.bridge, device=self.bridge.device)
})
elif self.bridge.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which "
f"is not part of virtual chassis {self.device.virtual_chassis}."
'bridge': _(
"The selected bridge interface ({interface}) belongs to {device}, which is not part of virtual "
"chassis {virtual_chassis}."
).format(
interface=self.bridge, device=self.bridge.device, virtual_chassis=self.device.virtual_chassis
)
})
# LAG validation
# A virtual interface cannot have a parent LAG
if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None:
raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."})
raise ValidationError({'lag': _("Virtual interfaces cannot have a parent LAG interface.")})
# A LAG interface cannot be its own parent
if self.pk and self.lag_id == self.pk:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
raise ValidationError({'lag': _("A LAG interface cannot be its own parent.")})
# An interface's LAG must belong to the same device or virtual chassis
if self.lag and self.lag.device != self.device:
if self.device.virtual_chassis is None:
raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to a different device ({self.lag.device})."
'lag': _(
"The selected LAG interface ({lag}) belongs to a different device ({device})."
).format(lag=self.lag, device=self.lag.device)
})
elif self.lag.device.virtual_chassis != self.device.virtual_chassis:
raise ValidationError({
'lag': f"The selected LAG interface ({self.lag}) belongs to {self.lag.device}, which is not part "
f"of virtual chassis {self.device.virtual_chassis}."
'lag': _(
"The selected LAG interface ({lag}) belongs to {device}, which is not part of virtual chassis "
"{virtual_chassis}.".format(
lag=self.lag, device=self.lag.device, virtual_chassis=self.device.virtual_chassis)
)
})
# PoE validation
@@ -781,52 +827,54 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Only physical interfaces may have a PoE mode/type assigned
if self.poe_mode and self.is_virtual:
raise ValidationError({
'poe_mode': "Virtual interfaces cannot have a PoE mode."
'poe_mode': _("Virtual interfaces cannot have a PoE mode.")
})
if self.poe_type and self.is_virtual:
raise ValidationError({
'poe_type': "Virtual interfaces cannot have a PoE type."
'poe_type': _("Virtual interfaces cannot have a PoE type.")
})
# An interface with a PoE type set must also specify a mode
if self.poe_type and not self.poe_mode:
raise ValidationError({
'poe_type': "Must specify PoE mode when designating a PoE type."
'poe_type': _("Must specify PoE mode when designating a PoE type.")
})
# Wireless validation
# RF role & channel may only be set for wireless interfaces
if self.rf_role and not self.is_wireless:
raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
raise ValidationError({'rf_role': _("Wireless role may be set only on wireless interfaces.")})
if self.rf_channel and not self.is_wireless:
raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
raise ValidationError({'rf_channel': _("Channel may be set only on wireless interfaces.")})
# Validate channel frequency against interface type and selected channel (if any)
if self.rf_channel_frequency:
if not self.is_wireless:
raise ValidationError({
'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
'rf_channel_frequency': _("Channel frequency may be set only on wireless interfaces."),
})
if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
raise ValidationError({
'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
'rf_channel_frequency': _("Cannot specify custom frequency with channel selected."),
})
# Validate channel width against interface type and selected channel (if any)
if self.rf_channel_width:
if not self.is_wireless:
raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
raise ValidationError({'rf_channel_width': _("Channel width may be set only on wireless interfaces.")})
if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
# VLAN validation
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the "
f"interface's parent device, or it must be global."
'untagged_vlan': _("""
The untagged VLAN ({untagged_vlan}) must belong to the same site as the
interface's parent device, or it must be global.
""").format(untagged_vlan=self.untagged_vlan)
})
def save(self, *args, **kwargs):
@@ -888,15 +936,17 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
# Pass-through ports
#
class FrontPort(ModularComponentModel, CabledObjectModel):
class FrontPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
A pass-through port on the front of a Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
rear_port = models.ForeignKey(
@@ -905,6 +955,7 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
related_name='frontports'
)
rear_port_position = models.PositiveSmallIntegerField(
verbose_name=_('rear port position'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -938,29 +989,40 @@ class FrontPort(ModularComponentModel, CabledObjectModel):
# Validate rear port assignment
if self.rear_port.device != self.device:
raise ValidationError({
"rear_port": f"Rear port ({self.rear_port}) must belong to the same device"
"rear_port": _(
"Rear port ({rear_port}) must belong to the same device"
).format(rear_port=self.rear_port)
})
# Validate rear port position assignment
if self.rear_port_position > self.rear_port.positions:
raise ValidationError({
"rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port "
f"{self.rear_port.name} has only {self.rear_port.positions} positions"
"rear_port_position": _(
"Invalid rear port position ({rear_port_position}): Rear port {name} has only {positions} "
"positions."
).format(
rear_port_position=self.rear_port_position,
name=self.rear_port.name,
positions=self.rear_port.positions
)
})
class RearPort(ModularComponentModel, CabledObjectModel):
class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
"""
A pass-through port on the rear of a Device.
"""
type = models.CharField(
verbose_name=_('type'),
max_length=50,
choices=PortTypeChoices
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
positions = models.PositiveSmallIntegerField(
verbose_name=_('positions'),
default=1,
validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
@@ -981,8 +1043,9 @@ class RearPort(ModularComponentModel, CabledObjectModel):
frontport_count = self.frontports.count()
if self.positions < frontport_count:
raise ValidationError({
"positions": f"The number of positions cannot be less than the number of mapped front ports "
f"({frontport_count})"
"positions": _("""
The number of positions cannot be less than the number of mapped front ports
({frontport_count})""").format(frontport_count=frontport_count)
})
@@ -990,11 +1053,12 @@ class RearPort(ModularComponentModel, CabledObjectModel):
# Bays
#
class ModuleBay(ComponentModel):
class ModuleBay(ComponentModel, TrackingModelMixin):
"""
An empty space within a Device which can house a child device
"""
position = models.CharField(
verbose_name=_('position'),
max_length=30,
blank=True,
help_text=_('Identifier to reference when renaming installed components')
@@ -1006,14 +1070,14 @@ class ModuleBay(ComponentModel):
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
class DeviceBay(ComponentModel):
class DeviceBay(ComponentModel, TrackingModelMixin):
"""
An empty space within a Device which can house a child device
"""
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
related_name='parent_bay',
related_name=_('parent_bay'),
blank=True,
null=True
)
@@ -1028,22 +1092,22 @@ class DeviceBay(ComponentModel):
# Validate that the parent Device can have DeviceBays
if not self.device.device_type.is_parent_device:
raise ValidationError("This type of device ({}) does not support device bays.".format(
self.device.device_type
raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
device_type=self.device.device_type
))
# Cannot install a device into itself, obviously
if self.device == self.installed_device:
raise ValidationError("Cannot install a device into itself.")
raise ValidationError(_("Cannot install a device into itself."))
# Check that the installed device is not already installed elsewhere
if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
if current_bay and current_bay != self:
raise ValidationError({
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
current_bay
)
'installed_device': _(
"Cannot install the specified device; device is already installed in {bay}."
).format(bay=current_bay)
})
@@ -1057,6 +1121,7 @@ class InventoryItemRole(OrganizationalModel):
Inventory items may optionally be assigned a functional role.
"""
color = ColorField(
verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
@@ -1064,7 +1129,7 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk])
class InventoryItem(MPTTModel, ComponentModel):
class InventoryItem(MPTTModel, ComponentModel, TrackingModelMixin):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
@@ -1109,13 +1174,13 @@ class InventoryItem(MPTTModel, ComponentModel):
)
part_id = models.CharField(
max_length=50,
verbose_name='Part ID',
verbose_name=_('part ID'),
blank=True,
help_text=_('Manufacturer-assigned part identifier')
)
serial = models.CharField(
max_length=50,
verbose_name='Serial number',
verbose_name=_('serial number'),
blank=True
)
asset_tag = models.CharField(
@@ -1123,10 +1188,11 @@ class InventoryItem(MPTTModel, ComponentModel):
unique=True,
blank=True,
null=True,
verbose_name='Asset tag',
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this item')
)
discovered = models.BooleanField(
verbose_name=_('discovered'),
default=False,
help_text=_('This item was automatically discovered')
)
@@ -1153,7 +1219,7 @@ class InventoryItem(MPTTModel, ComponentModel):
# An InventoryItem cannot be its own parent
if self.pk and self.parent_id == self.pk:
raise ValidationError({
"parent": "Cannot assign self as parent."
"parent": _("Cannot assign self as parent.")
})
# Validation for moving InventoryItems
@@ -1161,13 +1227,13 @@ class InventoryItem(MPTTModel, ComponentModel):
# 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."
"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")
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:
@@ -1175,5 +1241,5 @@ class InventoryItem(MPTTModel, ComponentModel):
else:
if self.component and self.component.device != self.device:
raise ValidationError({
"device": "Cannot assign inventory item to component on another device"
"device": _("Cannot assign inventory item to component on another device")
})

View File

@@ -12,7 +12,7 @@ from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from dcim.constants import *
@@ -21,7 +21,8 @@ from extras.querysets import ConfigContextModelQuerySet
from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField
from utilities.tracking import TrackingModelMixin
from .device_components import *
from .mixins import WeightMixin
@@ -77,9 +78,11 @@ class DeviceType(PrimaryModel, WeightMixin):
related_name='device_types'
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
default_platform = models.ForeignKey(
@@ -88,9 +91,10 @@ class DeviceType(PrimaryModel, WeightMixin):
related_name='+',
blank=True,
null=True,
verbose_name='Default platform'
verbose_name=_('default platform')
)
part_number = models.CharField(
verbose_name=_('part number'),
max_length=50,
blank=True,
help_text=_('Discrete part number (optional)')
@@ -99,22 +103,23 @@ class DeviceType(PrimaryModel, WeightMixin):
max_digits=4,
decimal_places=1,
default=1.0,
verbose_name='Height (U)'
verbose_name=_('height (U)')
)
is_full_depth = models.BooleanField(
default=True,
verbose_name='Is full depth',
verbose_name=_('is full depth'),
help_text=_('Device consumes both front and rear rack faces')
)
subdevice_role = models.CharField(
max_length=50,
choices=SubdeviceRoleChoices,
blank=True,
verbose_name='Parent/child status',
verbose_name=_('parent/child status'),
help_text=_('Parent devices house child devices in device bays. Leave blank '
'if this device type is neither a parent nor a child.')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=DeviceAirflowChoices,
blank=True
@@ -128,12 +133,55 @@ class DeviceType(PrimaryModel, WeightMixin):
blank=True
)
# Counter fields
console_port_template_count = CounterCacheField(
to_model='dcim.ConsolePortTemplate',
to_field='device_type'
)
console_server_port_template_count = CounterCacheField(
to_model='dcim.ConsoleServerPortTemplate',
to_field='device_type'
)
power_port_template_count = CounterCacheField(
to_model='dcim.PowerPortTemplate',
to_field='device_type'
)
power_outlet_template_count = CounterCacheField(
to_model='dcim.PowerOutletTemplate',
to_field='device_type'
)
interface_template_count = CounterCacheField(
to_model='dcim.InterfaceTemplate',
to_field='device_type'
)
front_port_template_count = CounterCacheField(
to_model='dcim.FrontPortTemplate',
to_field='device_type'
)
rear_port_template_count = CounterCacheField(
to_model='dcim.RearPortTemplate',
to_field='device_type'
)
device_bay_template_count = CounterCacheField(
to_model='dcim.DeviceBayTemplate',
to_field='device_type'
)
module_bay_template_count = CounterCacheField(
to_model='dcim.ModuleBayTemplate',
to_field='device_type'
)
inventory_item_template_count = CounterCacheField(
to_model='dcim.InventoryItemTemplate',
to_field='device_type'
)
images = GenericRelation(
to='extras.ImageAttachment'
)
clone_fields = (
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
'manufacturer', 'default_platform', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight',
'weight_unit',
)
prerequisite_models = (
'dcim.Manufacturer',
@@ -232,9 +280,9 @@ class DeviceType(PrimaryModel, WeightMixin):
super().clean()
# U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5):
if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units."
'u_height': _("U height must be in increments of 0.5 rack units.")
})
# If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have
@@ -250,8 +298,8 @@ class DeviceType(PrimaryModel, WeightMixin):
)
if d.position not in u_available:
raise ValidationError({
'u_height': "Device {} in rack {} does not have sufficient space to accommodate a height of "
"{}U".format(d, d.rack, self.u_height)
'u_height': _("Device {} in rack {} does not have sufficient space to accommodate a height of "
"{}U").format(d, d.rack, self.u_height)
})
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
@@ -263,23 +311,23 @@ class DeviceType(PrimaryModel, WeightMixin):
if racked_instance_count:
url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
raise ValidationError({
'u_height': mark_safe(
f'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
f'mounted within racks.'
)
'u_height': mark_safe(_(
'Unable to set 0U height: Found <a href="{url}">{racked_instance_count} instances</a> already '
'mounted within racks.'
).format(url=url, racked_instance_count=racked_instance_count))
})
if (
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
) and self.pk and self.devicebaytemplates.count():
raise ValidationError({
'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
'subdevice_role': _("Must delete all device bay templates associated with this device before "
"declassifying it as a parent device.")
})
if self.u_height and self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD:
raise ValidationError({
'u_height': "Child device types must be 0U."
'u_height': _("Child device types must be 0U.")
})
def save(self, *args, **kwargs):
@@ -324,9 +372,11 @@ class ModuleType(PrimaryModel, WeightMixin):
related_name='module_types'
)
model = models.CharField(
verbose_name=_('model'),
max_length=100
)
part_number = models.CharField(
verbose_name=_('part number'),
max_length=50,
blank=True,
help_text=_('Discrete part number (optional)')
@@ -411,11 +461,12 @@ class DeviceRole(OrganizationalModel):
virtual machines as well.
"""
color = ColorField(
verbose_name=_('color'),
default=ColorChoices.COLOR_GREY
)
vm_role = models.BooleanField(
default=True,
verbose_name='VM Role',
verbose_name=_('VM role'),
help_text=_('Virtual machines may be assigned to this role')
)
config_template = models.ForeignKey(
@@ -432,9 +483,8 @@ class DeviceRole(OrganizationalModel):
class Platform(OrganizationalModel):
"""
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
specifying a NAPALM driver.
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". A
Platform may optionally be associated with a particular Manufacturer.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
@@ -451,18 +501,6 @@ class Platform(OrganizationalModel):
blank=True,
null=True
)
napalm_driver = models.CharField(
max_length=50,
blank=True,
verbose_name='NAPALM driver',
help_text=_('The name of the NAPALM driver to use when interacting with devices')
)
napalm_args = models.JSONField(
blank=True,
null=True,
verbose_name='NAPALM arguments',
help_text=_('Additional arguments to pass when initiating the NAPALM driver (JSON format)')
)
def get_absolute_url(self):
return reverse('dcim:platform', args=[self.pk])
@@ -482,7 +520,7 @@ def update_interface_bridges(device, interface_templates, module=None):
interface.save()
class Device(PrimaryModel, ConfigContextModel):
class Device(PrimaryModel, ConfigContextModel, TrackingModelMixin):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
@@ -499,7 +537,7 @@ class Device(PrimaryModel, ConfigContextModel):
on_delete=models.PROTECT,
related_name='instances'
)
device_role = models.ForeignKey(
role = models.ForeignKey(
to='dcim.DeviceRole',
on_delete=models.PROTECT,
related_name='devices',
@@ -520,6 +558,7 @@ class Device(PrimaryModel, ConfigContextModel):
null=True
)
name = models.CharField(
verbose_name=_('name'),
max_length=64,
blank=True,
null=True
@@ -533,7 +572,7 @@ class Device(PrimaryModel, ConfigContextModel):
serial = models.CharField(
max_length=50,
blank=True,
verbose_name='Serial number',
verbose_name=_('serial number'),
help_text=_("Chassis serial number, assigned by the manufacturer")
)
asset_tag = models.CharField(
@@ -541,7 +580,7 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True,
null=True,
unique=True,
verbose_name='Asset tag',
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this device')
)
site = models.ForeignKey(
@@ -568,22 +607,24 @@ class Device(PrimaryModel, ConfigContextModel):
decimal_places=1,
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
verbose_name='Position (U)',
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)],
verbose_name=_('position (U)'),
help_text=_('The lowest-numbered unit occupied by the device')
)
face = models.CharField(
max_length=50,
blank=True,
choices=DeviceFaceChoices,
verbose_name='Rack face'
verbose_name=_('rack face')
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=DeviceStatusChoices,
default=DeviceStatusChoices.STATUS_ACTIVE
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=DeviceAirflowChoices,
blank=True
@@ -594,7 +635,7 @@ class Device(PrimaryModel, ConfigContextModel):
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv4'
verbose_name=_('primary IPv4')
)
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
@@ -602,7 +643,15 @@ class Device(PrimaryModel, ConfigContextModel):
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv6'
verbose_name=_('primary IPv6')
)
oob_ip = models.OneToOneField(
to='ipam.IPAddress',
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True,
verbose_name=_('out-of-band IP')
)
cluster = models.ForeignKey(
to='virtualization.Cluster',
@@ -619,12 +668,14 @@ class Device(PrimaryModel, ConfigContextModel):
null=True
)
vc_position = models.PositiveSmallIntegerField(
verbose_name=_('VC position'),
blank=True,
null=True,
validators=[MaxValueValidator(255)],
help_text=_('Virtual chassis position')
)
vc_priority = models.PositiveSmallIntegerField(
verbose_name=_('VC priority'),
blank=True,
null=True,
validators=[MaxValueValidator(255)],
@@ -637,6 +688,64 @@ class Device(PrimaryModel, ConfigContextModel):
blank=True,
null=True
)
latitude = models.DecimalField(
verbose_name=_('latitude'),
max_digits=8,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
longitude = models.DecimalField(
verbose_name=_('longitude'),
max_digits=9,
decimal_places=6,
blank=True,
null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
)
# Counter fields
console_port_count = CounterCacheField(
to_model='dcim.ConsolePort',
to_field='device'
)
console_server_port_count = CounterCacheField(
to_model='dcim.ConsoleServerPort',
to_field='device'
)
power_port_count = CounterCacheField(
to_model='dcim.PowerPort',
to_field='device'
)
power_outlet_count = CounterCacheField(
to_model='dcim.PowerOutlet',
to_field='device'
)
interface_count = CounterCacheField(
to_model='dcim.Interface',
to_field='device'
)
front_port_count = CounterCacheField(
to_model='dcim.FrontPort',
to_field='device'
)
rear_port_count = CounterCacheField(
to_model='dcim.RearPort',
to_field='device'
)
device_bay_count = CounterCacheField(
to_model='dcim.DeviceBay',
to_field='device'
)
module_bay_count = CounterCacheField(
to_model='dcim.ModuleBay',
to_field='device'
)
inventory_item_count = CounterCacheField(
to_model='dcim.InventoryItem',
to_field='device'
)
# Generic relations
contacts = GenericRelation(
@@ -649,7 +758,7 @@ class Device(PrimaryModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = (
'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'device_type', 'role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow',
'cluster', 'virtual_chassis',
)
prerequisite_models = (
@@ -669,7 +778,7 @@ class Device(PrimaryModel, ConfigContextModel):
Lower('name'), 'site',
name='%(app_label)s_%(class)s_unique_name_site',
condition=Q(tenant__isnull=True),
violation_error_message="Device name must be unique per site."
violation_error_message=_("Device name must be unique per site.")
),
models.UniqueConstraint(
fields=('rack', 'position', 'face'),
@@ -699,48 +808,68 @@ class Device(PrimaryModel, ConfigContextModel):
def get_absolute_url(self):
return reverse('dcim:device', args=[self.pk])
@property
def device_role(self):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
return self.role
@device_role.setter
def device_role(self, value):
"""
For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
"""
self.role = value
def clean(self):
super().clean()
# Validate site/location/rack combination
if self.rack and self.site != self.rack.site:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to site {self.site}.",
'rack': _("Rack {rack} does not belong to site {site}.").format(rack=self.rack, site=self.site),
})
if self.location and self.site != self.location.site:
raise ValidationError({
'location': f"Location {self.location} does not belong to site {self.site}.",
'location': _(
"Location {location} does not belong to site {site}."
).format(location=self.location, site=self.site)
})
if self.rack and self.location and self.rack.location != self.location:
raise ValidationError({
'rack': f"Rack {self.rack} does not belong to location {self.location}.",
'rack': _(
"Rack {rack} does not belong to location {location}."
).format(rack=self.rack, location=self.location)
})
if self.rack is None:
if self.face:
raise ValidationError({
'face': "Cannot select a rack face without assigning a rack.",
'face': _("Cannot select a rack face without assigning a rack."),
})
if self.position:
raise ValidationError({
'position': "Cannot select a rack position without assigning a rack.",
'position': _("Cannot select a rack position without assigning a rack."),
})
# Validate rack position and face
if self.position and self.position % decimal.Decimal(0.5):
raise ValidationError({
'position': "Position must be in increments of 0.5 rack units."
'position': _("Position must be in increments of 0.5 rack units.")
})
if self.position and not self.face:
raise ValidationError({
'face': "Must specify rack face when defining rack position.",
'face': _("Must specify rack face when defining rack position."),
})
# Prevent 0U devices from being assigned to a specific position
if hasattr(self, 'device_type'):
if self.position and self.device_type.u_height == 0:
raise ValidationError({
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."
'position': _(
"A U0 device type ({device_type}) cannot be assigned to a rack position."
).format(device_type=self.device_type)
})
if self.rack:
@@ -749,13 +878,17 @@ class Device(PrimaryModel, ConfigContextModel):
# Child devices cannot be assigned to a rack face/unit
if self.device_type.is_child_device and self.face:
raise ValidationError({
'face': "Child device types cannot be assigned to a rack face. This is an attribute of the "
"parent device."
'face': _(
"Child device types cannot be assigned to a rack face. This is an attribute of the parent "
"device."
)
})
if self.device_type.is_child_device and self.position:
raise ValidationError({
'position': "Child device types cannot be assigned to a rack position. This is an attribute of "
"the parent device."
'position': _(
"Child device types cannot be assigned to a rack position. This is an attribute of the "
"parent device."
)
})
# Validate rack space
@@ -766,19 +899,23 @@ class Device(PrimaryModel, ConfigContextModel):
)
if self.position and self.position not in available_units:
raise ValidationError({
'position': f"U{self.position} is already occupied or does not have sufficient space to "
f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)"
'position': _(
"U{position} is already occupied or does not have sufficient space to accommodate this "
"device type: {device_type} ({u_height}U)"
).format(
position=self.position, device_type=self.device_type, u_height=self.device_type.u_height
)
})
except DeviceType.DoesNotExist:
pass
# Validate primary IP addresses
# Validate primary & OOB IP addresses
vc_interfaces = self.vc_interfaces(if_master=False)
if self.primary_ip4:
if self.primary_ip4.family != 4:
raise ValidationError({
'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
'primary_ip4': _("{primary_ip4} is not an IPv4 address.").format(primary_ip4=self.primary_ip4)
})
if self.primary_ip4.assigned_object in vc_interfaces:
pass
@@ -786,12 +923,14 @@ class Device(PrimaryModel, ConfigContextModel):
pass
else:
raise ValidationError({
'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device."
'primary_ip4': _(
"The specified IP address ({primary_ip4}) is not assigned to this device."
).format(primary_ip4=self.primary_ip4)
})
if self.primary_ip6:
if self.primary_ip6.family != 6:
raise ValidationError({
'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
'primary_ip6': _("{primary_ip6} is not an IPv6 address.").format(primary_ip6=self.primary_ip6m)
})
if self.primary_ip6.assigned_object in vc_interfaces:
pass
@@ -799,27 +938,43 @@ class Device(PrimaryModel, ConfigContextModel):
pass
else:
raise ValidationError({
'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device."
'primary_ip6': _(
"The specified IP address ({primary_ip6}) is not assigned to this device."
).format(primary_ip6=self.primary_ip6)
})
if self.oob_ip:
if self.oob_ip.assigned_object in vc_interfaces:
pass
elif self.oob_ip.nat_inside is not None and self.oob_ip.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
'oob_ip': f"The specified IP address ({self.oob_ip}) is not assigned to this device."
})
# Validate manufacturer/platform
if hasattr(self, 'device_type') and self.platform:
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
raise ValidationError({
'platform': f"The assigned platform is limited to {self.platform.manufacturer} device types, but "
f"this device's type belongs to {self.device_type.manufacturer}."
'platform': _(
"The assigned platform is limited to {platform_manufacturer} device types, but this device's "
"type belongs to {device_type_manufacturer}."
).format(
platform_manufacturer=self.platform.manufacturer,
device_type_manufacturer=self.device_type.manufacturer
)
})
# A Device can only be assigned to a Cluster in the same Site (or no Site)
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
raise ValidationError({
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
'cluster': _("The assigned cluster belongs to a different site ({})").format(self.cluster.site)
})
# Validate virtual chassis assignment
if self.virtual_chassis and self.vc_position is None:
raise ValidationError({
'vc_position': "A device assigned to a virtual chassis must have its position defined."
'vc_position': _("A device assigned to a virtual chassis must have its position defined.")
})
def _instantiate_components(self, queryset, bulk_create=True):
@@ -922,8 +1077,8 @@ class Device(PrimaryModel, ConfigContextModel):
"""
if self.config_template:
return self.config_template
if self.device_role.config_template:
return self.device_role.config_template
if self.role.config_template:
return self.role.config_template
if self.platform and self.platform.config_template:
return self.platform.config_template
@@ -1004,6 +1159,7 @@ class Module(PrimaryModel, ConfigContextModel):
related_name='instances'
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=ModuleStatusChoices,
default=ModuleStatusChoices.STATUS_ACTIVE
@@ -1011,14 +1167,14 @@ class Module(PrimaryModel, ConfigContextModel):
serial = models.CharField(
max_length=50,
blank=True,
verbose_name='Serial number'
verbose_name=_('serial number')
)
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
unique=True,
verbose_name='Asset tag',
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this device')
)
@@ -1041,7 +1197,9 @@ class Module(PrimaryModel, ConfigContextModel):
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})."
_("Module must be installed within a module bay belonging to the assigned device ({device}).").format(
device=self.device
)
)
def save(self, *args, **kwargs):
@@ -1139,13 +1297,21 @@ class VirtualChassis(PrimaryModel):
null=True
)
name = models.CharField(
verbose_name=_('name'),
max_length=64
)
domain = models.CharField(
verbose_name=_('domain'),
max_length=30,
blank=True
)
# Counter fields
member_count = CounterCacheField(
to_model='dcim.Device',
to_field='virtual_chassis'
)
class Meta:
ordering = ['name']
verbose_name_plural = 'virtual chassis'
@@ -1163,7 +1329,9 @@ class VirtualChassis(PrimaryModel):
# VirtualChassis.)
if self.pk and self.master and self.master not in self.members.all():
raise ValidationError({
'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
'master': _("The selected master ({master}) is not assigned to this virtual chassis.").format(
master=self.master
)
})
def delete(self, *args, **kwargs):
@@ -1176,10 +1344,10 @@ class VirtualChassis(PrimaryModel):
lag__device=F('device')
)
if interfaces:
raise ProtectedError(
f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
interfaces
)
raise ProtectedError(_(
"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG "
"interfaces."
).format(self=self, interfaces=InterfaceSpeedChoices))
return super().delete(*args, **kwargs)
@@ -1193,14 +1361,17 @@ class VirtualDeviceContext(PrimaryModel):
null=True
)
name = models.CharField(
verbose_name=_('name'),
max_length=64
)
status = models.CharField(
verbose_name=_('status'),
max_length=50,
choices=VirtualDeviceContextStatusChoices,
)
identifier = models.PositiveSmallIntegerField(
help_text='Numeric identifier unique to the parent device',
verbose_name=_('identifier'),
help_text=_('Numeric identifier unique to the parent device'),
blank=True,
null=True,
)
@@ -1210,7 +1381,7 @@ class VirtualDeviceContext(PrimaryModel):
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv4'
verbose_name=_('primary IPv4')
)
primary_ip6 = models.OneToOneField(
to='ipam.IPAddress',
@@ -1218,7 +1389,7 @@ class VirtualDeviceContext(PrimaryModel):
related_name='+',
blank=True,
null=True,
verbose_name='Primary IPv6'
verbose_name=_('primary IPv6')
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -1228,6 +1399,7 @@ class VirtualDeviceContext(PrimaryModel):
null=True
)
comments = models.TextField(
verbose_name=_('comments'),
blank=True
)
@@ -1273,7 +1445,9 @@ class VirtualDeviceContext(PrimaryModel):
continue
if primary_ip.family != family:
raise ValidationError({
f'primary_ip{family}': f"{primary_ip} is not an IPv{family} address."
f'primary_ip{family}': _(
"{primary_ip} is not an IPv{family} address."
).format(family=family, primary_ip=primary_ip)
})
device_interfaces = self.device.vc_interfaces(if_master=False)
if primary_ip.assigned_object not in device_interfaces:

View File

@@ -1,17 +1,20 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import gettext_lazy as _
from dcim.choices import *
from utilities.utils import to_grams
class WeightMixin(models.Model):
weight = models.DecimalField(
verbose_name=_('weight'),
max_digits=8,
decimal_places=2,
blank=True,
null=True
)
weight_unit = models.CharField(
verbose_name=_('weight unit'),
max_length=50,
choices=WeightUnitChoices,
blank=True,
@@ -40,4 +43,4 @@ class WeightMixin(models.Model):
# Validate weight and weight_unit
if self.weight and not self.weight_unit:
raise ValidationError("Must specify a unit when setting a weight")
raise ValidationError(_("Must specify a unit when setting a weight"))

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