Compare commits

...

345 Commits

Author SHA1 Message Date
Jeremy Stretch
472a45ddec Merge pull request #4167 from netbox-community/develop
Release v2.7.6
2020-02-13 21:50:44 -05:00
Jeremy Stretch
0863145c7f Release v2.7.6 2020-02-13 21:46:03 -05:00
Jeremy Stretch
909323663e Fixes #4166: Fix schema migrations to enforce maximum character length for naturalized fields 2020-02-13 21:41:00 -05:00
Jeremy Stretch
4d50cad6ed Post-release version bump 2020-02-13 15:39:08 -05:00
Jeremy Stretch
120cbb0159 Merge pull request #4165 from netbox-community/develop
Release v2.7.5
2020-02-13 15:36:50 -05:00
Jeremy Stretch
08ce024473 Release v2.7.5 2020-02-13 15:32:09 -05:00
Jeremy Stretch
807c2f048d Changelog for #3984 2020-02-13 15:16:07 -05:00
Jeremy Stretch
fafcdf7def Merge pull request #4161 from dstarner/issue-3984-redis-sentinel-conn
Fixes #3984: Allow for Redis Sentinel Connection Configuration
2020-02-13 15:14:42 -05:00
Jeremy Stretch
92fab048d1 Add tests for naturalization functions 2020-02-13 15:13:43 -05:00
Jeremy Stretch
35498c17d7 Updated the style guide 2020-02-13 11:04:07 -05:00
Jeremy Stretch
874e59b01a Closes #4060: Move secrets panel on home page to righthand column 2020-02-13 10:30:12 -05:00
Dan Starner
72f0e31b84 fixed extraneous linting error 2020-02-13 10:27:56 -05:00
Dan Starner
ba9a2956a8 documentation on redis sentinel 2020-02-13 10:26:03 -05:00
Dan Starner
3538eeda14 allow for redis sentinel connection 2020-02-13 10:26:03 -05:00
Jeremy Stretch
0c89534bfb Closes #4160: Link to full database configuration parameters in configuration docs 2020-02-13 10:08:10 -05:00
Jeremy Stretch
47b15aacef Changelog for #4159 2020-02-13 09:48:12 -05:00
Jeremy Stretch
3e0ab79977 Merge pull request #4162 from netbox-community/4159-caching_redis_using_wrong_key
Fixes: #4159 - Corrects settings.py to use CACHING_REDIS
2020-02-13 09:46:41 -05:00
Jeremy Stretch
344fa72357 renaturalize command should not skip any values 2020-02-13 09:33:01 -05:00
Daniel Sheppard
617fc7659f Fixes: #4159 - Corrects settings.py to use CACHING_REDIS instead of WEBHOOKS_REDIS 2020-02-13 08:26:47 -06:00
Jeremy Stretch
0d91b6b74b Merge pull request #4102 from kobayashi/4093-vm-status-choice
Fixes #4093: Additional status choices for vms
2020-02-12 22:58:43 -05:00
kobayashi
335343642b additional status choices for vms 2020-02-12 22:43:40 -05:00
kobayashi
bc7f5fb33a Modify offline status color for vm 2020-02-12 22:19:17 -05:00
kobayashi
ca56fc709a Fixes #4093: Add decommissioning for vms 2020-02-12 22:19:17 -05:00
Jeremy Stretch
a08ee68033 Exempt pull requests from stalebot 2020-02-12 21:56:23 -05:00
Jeremy Stretch
0d57cb0033 Changelog & docs for #3766 2020-02-12 16:54:40 -05:00
Jeremy Stretch
53804d39bb Merge pull request #4142 from netbox-community/3766-enable_widgets_in_script_fields
Fixes: #3766 - Enable widget usage in scripts
2020-02-12 16:48:08 -05:00
Jeremy Stretch
1e221cd9bb Update changelog 2020-02-12 16:28:19 -05:00
Jeremy Stretch
0c942f18c1 Merge pull request #4068 from netbox-community/4034-prefix_ordering_tests
Fixes: #4034 - Adds Prefix & IPAddress Ordering Tests
2020-02-12 16:27:02 -05:00
Jeremy Stretch
00d32f0a7d Merge pull request #4156 from netbox-community/4153-renaturalize-command
Closes #4153: Add a "renaturalize" management command
2020-02-12 16:20:52 -05:00
Jeremy Stretch
df3fef8bb1 Merge pull request #4154 from netbox-community/4138-device_bays_in_rack_elevations
Fixes: #4138 - Add Device Bay count to rack elevation
2020-02-12 16:19:57 -05:00
Jeremy Stretch
5cc24c055b Tweak docs link for send_mail() to reference stable; formatting 2020-02-12 16:18:08 -05:00
Jeremy Stretch
4064c32a7f Fix nav menu link for configcontext_add 2020-02-12 16:16:23 -05:00
Joshua Corrick
6d7c5d51fe Add email testing example (#4152)
* Add email testing example

Includes an example provided by  Jeremy

* Updated with suggestions

Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-02-12 16:15:05 -05:00
Jeremy Stretch
64c0059dd8 Merge pull request #4155 from hSaria/4150-yaml-nested-tags
Fixes #4150: Replace OrderedDict with Dict when rendering YAML
2020-02-12 16:13:32 -05:00
Jeremy Stretch
d0ece2e48d Merge pull request #4144 from netbox-community/3986-rack_positions_svg
Fixes: #3986 - Convert rack units to part of SVG rendered document
2020-02-12 16:06:05 -05:00
Jeremy Stretch
139f18b2e5 Closes #4153: Add a management command to manually renaturalize applicable objects (from #3799) 2020-02-12 15:59:37 -05:00
Saria Hajjar
8eea0331bf Fixes #4150: Replace OrderedDict with Dict when rendering YAML 2020-02-12 19:59:04 +00:00
Daniel Sheppard
62d6e02d6b Modify _draw_elevations
* Add legend_width argument, variable & constant
* Applied legend_width variable where required
* Removed U prefix
2020-02-12 13:15:29 -06:00
Jeremy Stretch
a8601bb1fd Merge pull request #4151 from netbox-community/4146-secretrole-enforcement
Fixes #4146: Fix SecretRole permissions enforcement
2020-02-12 13:51:44 -05:00
Daniel Sheppard
fe452735be Add Device Bay count to rack elevation names 2020-02-12 12:48:13 -06:00
Jeremy Stretch
3b1128f8f3 Establish standard test cases for all models 2020-02-12 13:25:56 -05:00
Daniel Sheppard
0402323ef9 Fixes: #4130
Corrects service name for the RQ worker service in docs/installation/upgrading.md
2020-02-12 11:26:40 -06:00
Jeremy Stretch
5bf85597ed Fixes #4146: Fix SecretRole permissions enforcement 2020-02-12 11:13:32 -05:00
Jeremy Stretch
e4b910fe87 Fixes #4148: Remove dead link to topology maps docs page 2020-02-12 09:44:30 -05:00
Jeremy Stretch
24db573764 Merge pull request #3997 from hSaria/3995-navbar-overflow
Fixes #3995: Navbar scroll when overflowing
2020-02-12 09:41:48 -05:00
Jeremy Stretch
5befa533c6 Merge branch 'develop' into 3995-navbar-overflow 2020-02-12 09:40:31 -05:00
Dan Sheppard
15bc731f61 Convert rack units to part of SVG rendered document 2020-02-11 23:31:51 -06:00
Dan Sheppard
8fb4988fa1 Fix typo in docstring 2020-02-11 21:01:43 -06:00
Daniel Sheppard
ab378ed218 Fixes: #4130
Corrects service name for the RQ worker service in docs/installation/upgrading.md
2020-02-11 20:39:50 -06:00
Daniel Sheppard
56bb053146 Fix test error 2020-02-11 20:27:02 -06:00
Daniel Sheppard
3c3cca8ec1 Enable widget usage (APISelect, APIMultipleSelect, StaticSelect2, etc) in scripts 2020-02-11 20:12:02 -06:00
Dan Sheppard
908586c93a Fix PEP8 errors 2020-02-11 18:21:58 -06:00
Jeremy Stretch
2ab382eec5 Fixes #4137: Disable occupied terminations when connecting a cable to a circuit 2020-02-11 14:56:02 -05:00
Jeremy Stretch
2503978555 Add navigation menu link for adding config contexts 2020-02-11 14:32:41 -05:00
Anton Tokarev
55886d6793 fix typo (#4135)
Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-02-11 14:22:32 -05:00
Jeremy Stretch
009c0ba31c Fixes #4134: Device power ports and outlets should inherit type from the parent device type 2020-02-11 14:18:45 -05:00
Jeremy Stretch
ec53e1c74c Merge pull request #4136 from netbox-community/3912-remove-chained-fields
Closes #3912: Remove chained form fields
2020-02-11 14:01:55 -05:00
Jeremy Stretch
7177fcfa61 Use DynamicModelChoiceField for all fields using APISelect 2020-02-11 12:25:40 -05:00
Jeremy Stretch
fb56d5bc66 Account for initial data when binding a DynamicModelChoiceField 2020-02-11 10:21:44 -05:00
Jeremy Stretch
221805a63e Use TreeNode choice fields for region assignment 2020-02-11 09:50:33 -05:00
Jeremy Stretch
da68968d75 Replace FilterChoiceField with DynamicModelMultipleChoiceField 2020-02-11 09:33:41 -05:00
Dan Sheppard
ca795f729f Merge branch '4034-prefix_ordering_tests' of https://github.com/netbox-community/netbox into 4034-prefix_ordering_tests
 Conflicts:
	netbox/ipam/tests/test_ordering.py
2020-02-10 21:38:53 -06:00
Dan Sheppard
ff4e6bd166 Update tests
Add docstrings
Consolidate tests
2020-02-10 21:37:40 -06:00
Jeremy Stretch
5ea30c8628 Replace ChainedModelChoiceField with DynamicModelChoiceField 2020-02-10 17:23:52 -05:00
Jeremy Stretch
a54fcda781 Merge pull request #4133 from netbox-community/4083-serializer-null-choices
Fixes #4083: Permit nullifying applicable choice fields via API requests
2020-02-10 15:16:38 -05:00
Jeremy Stretch
7388fa3556 Fixes #4083: Permit nullifying applicable choice fields via API requests 2020-02-10 15:10:33 -05:00
Jeremy Stretch
a966a4c8ac Closes #4129: Add individual deletion views for device type components 2020-02-10 11:43:51 -05:00
Jeremy Stretch
ebef48e472 Merge pull request #4075 from kobayashi/3507-filter-by-devices
Fixes #3507: Filtering IP by multiple devices
2020-02-10 11:11:18 -05:00
Jeremy Stretch
26ca6b4a84 #4108: Fix null choice population 2020-02-10 10:57:23 -05:00
Jeremy Stretch
3da6f22479 Merge pull request #4128 from netbox-community/4108-apiselect-performance
Closes #4108: Extraneous queryset evaluation by FilterChoiceFields
2020-02-10 10:26:49 -05:00
Jeremy Stretch
d4789b7c9e Changelog for #4108 2020-02-10 10:20:06 -05:00
Jeremy Stretch
5008526db1 Set a default self.to_field_name for FilterChoiceField 2020-02-10 10:08:20 -05:00
Jeremy Stretch
009fc4f301 Remove custom template for APISelect widget 2020-02-10 10:02:42 -05:00
Jeremy Stretch
55f5ede970 Standardize usage of FilterChoiceField 2020-02-10 09:58:33 -05:00
Jeremy Stretch
5ddfde2214 Clean up unneeded code relevant to FilterChoiceField 2020-02-10 09:44:19 -05:00
kobayashi
505cb9cab8 Enabled filtering virtual chassis devices 2020-02-09 00:58:54 -05:00
kobayashi
d5c4a9d159 Fixes #3507: Filtering IP by multiple devices 2020-02-09 00:42:01 -05:00
Jeremy Stretch
885ea8a4d5 Override get_bound_field() on FilterChoiceFieldMixin to restrict the queryset of bound fields 2020-02-07 18:04:40 -05:00
Jeremy Stretch
202a0a0e73 Merge pull request #4122 from netbox-community/3799-natural-ordering-field
Closes #3799: Remove NaturalOrderingManager
2020-02-07 16:26:45 -05:00
Jeremy Stretch
5bfd65b5fe Changelog for #3799 2020-02-07 16:18:15 -05:00
Jeremy Stretch
7c74d2ca65 Convert interface models to use NaturalOrderingField 2020-02-07 15:58:03 -05:00
Jeremy Stretch
9adeed55fb Update table field ordering 2020-02-07 12:44:51 -05:00
Jeremy Stretch
12c7d83a91 Fix PowerOutlet migrations 2020-02-07 12:43:53 -05:00
Jeremy Stretch
dc1b7874ff Store empty names as null 2020-02-07 12:24:38 -05:00
Jeremy Stretch
c72a353733 Enable reverse migration 2020-02-07 12:23:52 -05:00
Jeremy Stretch
35511cfdc1 Remove NaturalOrderingManager 2020-02-07 11:59:32 -05:00
Jeremy Stretch
099c446f38 Convert remaining DCIM models to use NaturalOrderingField 2020-02-07 11:59:16 -05:00
Jeremy Stretch
705c352885 Convert device component templates to use NaturalOrderingField 2020-02-07 11:42:12 -05:00
Jeremy Stretch
12d09e2274 Convert device components to use NaturalOrderingField 2020-02-07 11:36:58 -05:00
Jeremy Stretch
b271fd32bd Introduce NaturalOrderingField 2020-02-07 11:36:22 -05:00
Jeremy Stretch
b3c2b78e8a Chnagelog for #4100 2020-02-07 09:24:51 -05:00
Jeremy Stretch
97a89948c8 Merge pull request #4115 from hSaria/4100-interface-filter-device
Fixes #4100: Added device field to device components filter forms
2020-02-07 09:20:49 -05:00
Jeremy Stretch
1e61fcb485 Merge pull request #4117 from netbox-community/4116-component-bulk-actions
Closes #4116: Enable bulk edit and delete functions for device component list views
2020-02-06 22:03:25 -05:00
Jeremy Stretch
4cc9f2f67d Changelog for #4116 2020-02-06 21:52:10 -05:00
Jeremy Stretch
52257467c3 Tweak bulk edit views to dynamically remove device-dependent fields 2020-02-06 21:44:28 -05:00
Jeremy Stretch
4563749fd9 Enable bulk edit/delete views for all device components 2020-02-06 20:58:14 -05:00
Jeremy Stretch
6d242ec348 Correct typo 2020-02-06 20:46:19 -05:00
Saria Hajjar
d0e00162ed Account for header height 2020-02-07 00:14:05 +00:00
Saria Hajjar
21f2e0b131 Changed navbar scrollbar to auto 2020-02-06 23:30:58 +00:00
hSaria
6ac8d41323 Merge branch 'develop' into 3995-navbar-overflow 2020-02-06 23:18:24 +00:00
Saria Hajjar
bb9e1ad857 Fixes #4100: Added device field to device components filter forms 2020-02-06 23:10:38 +00:00
Jeremy Stretch
98de88de90 Standardize URL paths for VM interfaces 2020-02-06 17:40:04 -05:00
Jeremy Stretch
c571aa68be Changelog for #4113 2020-02-06 16:05:30 -05:00
Jeremy Stretch
091d860ae5 Merge pull request #4114 from netbox-community/4113-component-bulk-editing
Closes #4113: Add bulk edit functionality for device type components
2020-02-06 16:03:37 -05:00
Jeremy Stretch
b5344b0aa7 Fix table links for adding new components 2020-02-06 15:54:52 -05:00
Jeremy Stretch
17e0054941 Fix up bulk editing forms 2020-02-06 15:51:51 -05:00
Jeremy Stretch
1b5969a5ee Add tests for DeviceType component bulk edit views 2020-02-06 15:33:47 -05:00
Jeremy Stretch
3378287b0c Add bulk edit views for DeviceType components 2020-02-06 15:29:10 -05:00
Jeremy Stretch
077d692d6d Merge pull request #4112 from netbox-community/4109-devicetype-component-tests
Closes #4109: Add view tests for device component templates
2020-02-06 14:57:10 -05:00
Jeremy Stretch
5620fdc63e Add tests for device type component views 2020-02-06 14:39:36 -05:00
Jeremy Stretch
f7ca97d51f Fix bulk edit buttons 2020-02-06 13:19:25 -05:00
Jeremy Stretch
d400f92ee8 Overhaul device component template creation views/URLs 2020-02-06 13:13:40 -05:00
Jeremy Stretch
c1792653cc Rename device component edit URLs 2020-02-06 12:18:53 -05:00
Jeremy Stretch
aebfb143e0 Rename component template deletion URLs 2020-02-06 12:16:28 -05:00
Jeremy Stretch
ef4ea06f5d Reorganized device component URLs (again) 2020-02-06 11:51:27 -05:00
Jeremy Stretch
85729f3df8 Simplify help_text for ExpandableNameField 2020-02-06 11:44:29 -05:00
Jeremy Stretch
a2475ee501 Remove obsolete ComponentForm 2020-02-06 11:36:25 -05:00
Jeremy Stretch
71601aad39 Replace HiddenInput widget on device component creation forms with APISelect 2020-02-06 11:29:50 -05:00
Jeremy Stretch
c1c8b5e816 Introduce InventoryItemCreateView to bring inventory items up to par with other components 2020-02-06 10:59:13 -05:00
Jeremy Stretch
2296cdc222 Fixes #4090: Render URL custom fields as links under object view 2020-02-05 17:08:39 -05:00
Jeremy Stretch
070b41e694 Reduce number of changelog entries to improve page loading time 2020-02-05 17:01:46 -05:00
Jeremy Stretch
d04626e75f Fixes #4099: Linkify interfaces on global interfaces list 2020-02-05 16:52:42 -05:00
Jeremy Stretch
68738e683a Fixes #4091: Fix filtering of objects by custom fields using UI search form 2020-02-05 16:39:42 -05:00
Jeremy Stretch
3f2c74f5e7 Merge pull request #4096 from elju/patch-1
Updated forms.py to match current naming (Fix for #4095)
2020-02-05 16:25:34 -05:00
Jeremy Stretch
a58bbccfd3 Fixes #4094: Convert unnecessary regular expressions to strings in URL patterns 2020-02-05 16:12:48 -05:00
Jeremy Stretch
b1e78fa3c4 Merge pull request #4098 from netbox-community/4086-device-component-urls
Closes #4086: Rename device component create/edit/delete URLs
2020-02-05 16:03:14 -05:00
Jeremy Stretch
0d3ff664b6 Move model_to_dict() into assertInstanceEqual(); clean up test data 2020-02-05 15:47:50 -05:00
Jeremy Stretch
b0c0ad7c82 Adapt component creation forms to infer parent device/VM from initial or bound data 2020-02-05 15:31:30 -05:00
Jeremy Stretch
0ad613e6b4 Enable bulk creation tests for device components 2020-02-05 15:22:57 -05:00
Jeremy Stretch
75906f7591 Move component bulk creation views to new URLs 2020-02-05 12:36:38 -05:00
El Ju
c49d977379 Updated forms.py to match current naming 2020-02-05 17:26:44 +01:00
Jeremy Stretch
6b9fa5e76f Enable tests for component bulk edit views 2020-02-05 11:14:07 -05:00
Jeremy Stretch
57a0cf0a33 Fix component bulk edit views 2020-02-05 10:28:39 -05:00
Jeremy Stretch
f8ce67c69f Tweak BulkEditView to improve handling of initial PK values 2020-02-05 09:56:49 -05:00
Jeremy Stretch
d0295f089d Fixes #4089: Selection of power outlet type during bulk update is optional 2020-02-04 20:49:42 -05:00
Jeremy Stretch
f805b57778 Adapt BulkEditView to not require a parent object for device components 2020-02-04 18:08:40 -05:00
Jeremy Stretch
3e79b9d26a Add InterfaceTestCase for virtual machines 2020-02-04 16:40:18 -05:00
Jeremy Stretch
c1639b7781 Move component bulk delete views to new URLs 2020-02-04 16:06:55 -05:00
Jeremy Stretch
fca347e49e Reorder URLs 2020-02-04 15:41:15 -05:00
Jeremy Stretch
32623148dc Post-release version bump 2020-02-04 15:06:58 -05:00
Jeremy Stretch
68fbd9b017 Merge pull request #4088 from netbox-community/develop
Release v2.7.4
2020-02-04 15:04:34 -05:00
Jeremy Stretch
11d67509e0 Release v2.7.4 2020-02-04 14:57:12 -05:00
Jeremy Stretch
c96fc6e21a Merge pull request #4087 from netbox-community/4085-dcim-component-view-tests
Closes #4085: Standardize device component view tests
2020-02-04 14:51:48 -05:00
Jeremy Stretch
763d9b9cf7 Convert all DCIM component view tests to use StandardTestCases 2020-02-04 14:25:02 -05:00
Jeremy Stretch
bece1155ee Introduce create_test_device() to reduce test setup boilerplate 2020-02-04 11:58:52 -05:00
Jeremy Stretch
cbe090cd3c Fixes #4084: Fix exception when creating an interface with tagged VLANs 2020-02-04 11:47:14 -05:00
Jeremy Stretch
c3a6a4520a #3894 follow-up 2020-02-04 10:09:22 -05:00
Jeremy Stretch
67e427403f Merge pull request #3894 from hSaria/2921-tags-select2
Fixes #2921: Replace tags filter with Select2 widget
2020-02-04 09:38:14 -05:00
Jeremy Stretch
0d41d12267 Merge branch 'develop' into 2921-tags-select2 2020-02-04 09:37:31 -05:00
Jeremy Stretch
efb7f151ec Changelog for #3313 2020-02-03 16:20:29 -05:00
Jeremy Stretch
fe22a8d0af Merge pull request #4011 from hSaria/3313-config-context-gui
Fixes #3313: YAML-format the config context in the GUI
2020-02-03 16:13:58 -05:00
Jeremy Stretch
ed99158391 Merge branch 'develop' into 3313-config-context-gui 2020-02-03 16:07:15 -05:00
Jeremy Stretch
b0f7feefa8 Changelog for #3886 2020-02-03 16:04:25 -05:00
Jeremy Stretch
fcd8e93e2e Merge pull request #4014 from hSaria/3886-config-context-cluster
Fixes #3886: Config context cluster (group)
2020-02-03 16:02:56 -05:00
Jeremy Stretch
173c530fab Merge pull request #4064 from netbox-community/3961-change-systemd-instructions
Fixes: #3961 - Update migrate-to-systemd.md documentation
2020-02-03 15:46:20 -05:00
Jeremy Stretch
0a87df48ab Update GitHub issue templates 2020-02-03 14:45:36 -05:00
Jeremy Stretch
eef79e1443 Fixes #4079: Fix assignment of power panel when bulk editing power feeds 2020-02-03 14:34:47 -05:00
Jeremy Stretch
91929aae1b Merge pull request #4080 from netbox-community/4077-view-tests
Closes #4077: Add tests for bulk edit/delete views
2020-02-03 14:32:56 -05:00
Jeremy Stretch
3f13441a5d Add view tests for power panels and power feeds 2020-02-03 14:25:06 -05:00
Jeremy Stretch
7b4f3e8261 Correct view for PowerFeed creation URL 2020-02-03 14:24:32 -05:00
Jeremy Stretch
d431efb7d4 Add bulk edit view tests 2020-02-03 13:53:19 -05:00
Jeremy Stretch
4aa694f044 Skip non-model fields when applying bulk changes 2020-02-03 13:07:46 -05:00
Jeremy Stretch
c3bd1881f5 Correct nullable_fields for ServiceBulkEditForm 2020-02-03 12:25:20 -05:00
Jeremy Stretch
a4aadf730c Correct default_return_url for TagBulkEditView 2020-02-03 11:59:43 -05:00
Jeremy Stretch
24ab082674 Add bulk delete view tests 2020-02-03 10:04:09 -05:00
Saria Hajjar
bceaa4a9a4 Corrected models for cluster and cluster group fields 2020-02-02 23:37:01 +00:00
Jeremy Stretch
5386ed438e Extend standard view test case to validate built-in CSV export 2020-01-31 17:09:50 -05:00
Jeremy Stretch
2ea95941e2 Removed obsolete CSV headers from DeviceType (export is now YAML-based) 2020-01-31 17:08:38 -05:00
Jeremy Stretch
f632b5bc29 Fixes #4067: Correct permission checked when creating a rack (vs. editing) 2020-01-31 16:12:46 -05:00
Jeremy Stretch
cea1e3d090 Fixes #4071: Enforce "view tag" permission on individual tag view 2020-01-31 16:10:16 -05:00
Jeremy Stretch
ce081a6e15 Merge pull request #4072 from netbox-community/4000-view-tests
Closes #4000: Add tests for the create, edit, and delete views of all models
2020-01-31 16:07:32 -05:00
Jeremy Stretch
eb9538d6da Clean up imports 2020-01-31 15:59:26 -05:00
Jeremy Stretch
e50eab2342 Convert virtualization view tests to use StandardTestCases 2020-01-31 15:57:33 -05:00
Jeremy Stretch
5517145ae3 Convert tenancy view tests to use StandardTestCases 2020-01-31 15:44:10 -05:00
Jeremy Stretch
e8e39dc5e3 Convert secrets view tests to use StandardTestCases 2020-01-31 15:37:58 -05:00
Jeremy Stretch
b361cb00f2 Convert IPAM view tests to use StandardTestCases 2020-01-31 15:19:10 -05:00
Jeremy Stretch
3668aa21fe Fix DeviceTypeTestCase permissions assignment for custom tests 2020-01-31 14:29:56 -05:00
Jeremy Stretch
8881bba696 Suppress tag view test until #4071 is fixed 2020-01-31 14:22:56 -05:00
Jeremy Stretch
250bda2bf6 Extend and correct evaluation of view permissions 2020-01-31 14:13:30 -05:00
Jeremy Stretch
936e3424bb Refactor model_to_dict() to better handle tags 2020-01-31 14:12:48 -05:00
Jeremy Stretch
ab7b921641 Convert extras view tests to StandardTestCases 2020-01-31 13:45:09 -05:00
Jeremy Stretch
c9d0dcecf3 model_to_dict(): Convert object lists to PK lists 2020-01-31 13:44:34 -05:00
Jeremy Stretch
86ef739c12 Migrate (most) DCIM view tests to use StandardTestCases 2020-01-31 12:32:33 -05:00
Jeremy Stretch
c14496d0c4 DeviceForm.manufacturer should not be a required field 2020-01-31 12:28:50 -05:00
Jeremy Stretch
a208cbdf0b model_to_dict(): Remove fields that start with an underscore 2020-01-31 12:14:51 -05:00
Jeremy Stretch
6a17be740b post_data(): Ignore iterables 2020-01-31 11:50:12 -05:00
Daniel Sheppard
8e9a0eeef0 Fix PEP8 errors and document functions 2020-01-31 10:05:57 -06:00
Daniel Sheppard
d746448d7d Fixes: #3961 - Edit migrate-to-systemd.md to closely match installation instructions under 3-http-daemon.md 2020-01-31 09:39:27 -06:00
Jeremy Stretch
7daf1df22d Add _get_url() for View test case 2020-01-31 10:30:13 -05:00
Jeremy Stretch
78d43a5d66 Move form/CSV data declaration under setUpTestData 2020-01-31 09:27:41 -05:00
Jeremy Stretch
939b5f2e29 Reorganize test classes to prevent unittest from running the base TestCases 2020-01-31 09:00:01 -05:00
Saria Hajjar
0d18c296a9 Set default config context format to JSON to maintain existing behavior 2020-01-31 11:11:42 +00:00
Jeremy Stretch
98cce7eee4 Added ViewTestCase (WIP) 2020-01-30 21:57:20 -05:00
Jeremy Stretch
e01c984c01 Introduced a custom model_to_dict() 2020-01-30 20:48:26 -05:00
Jeremy Stretch
4522a285e0 Fix headings 2020-01-30 20:05:27 -05:00
Jeremy Stretch
a44c4d14e4 Convert view tests under extras to the new TestCase 2020-01-30 18:13:02 -05:00
Jeremy Stretch
67fafb2b9d Use assertHttpStatus for evaluating HTTP response codes 2020-01-30 18:08:25 -05:00
Jeremy Stretch
179abcc79d Refactor APITestCase to subclass TestCase 2020-01-30 17:57:34 -05:00
Jeremy Stretch
316c0b6168 Merge pull request #4053 from netbox-community/4051-disable-makemigrations
Closes #4051: Disable the makemigrations management command
2020-01-30 16:50:40 -05:00
Jeremy Stretch
ac27759250 Merge branch 'develop' into 4051-disable-makemigrations 2020-01-30 16:49:15 -05:00
Jeremy Stretch
c8c9f78829 Documented the new DEVELOPER configuration parameter 2020-01-30 16:47:44 -05:00
Jeremy Stretch
61ac7c44ba Migrate view tests to use new TestCase class 2020-01-30 16:37:40 -05:00
Jeremy Stretch
43b2c36066 Introduced a custom TestCase 2020-01-30 16:19:51 -05:00
Jeremy Stretch
1a25f5a7f2 Fixes #4030: Fix exception when bulk editing interfaces (revised) 2020-01-30 15:12:10 -05:00
Jeremy Stretch
b9765b857d Merge pull request #4050 from netbox-community/568-customfield-csv-import
Closes #568: Extend CSV import to support custom fields
2020-01-30 14:04:57 -05:00
agrrajag
d0d2af4cab Update 3-http-daemon.md (#4055)
There was no documentation to move back into the netbox folder after installing/configuring nginx. You would move into nginx on line 42 then try and figure out why you couldn't copy gunicorn on line 113.
2020-01-30 14:00:37 -05:00
Jeremy Stretch
4b02d294ce Fixes #4052: Fix error when bulk importing interfaces to virtual machines 2020-01-30 13:55:39 -05:00
Jeremy Stretch
d9b8bc0422 Fix VM interfaces table header alignment 2020-01-30 13:39:50 -05:00
Saria Hajjar
7897ebb2ed Corrected changelog 2020-01-30 17:52:30 +00:00
Saria Hajjar
52f7ef4864 Merge branch 'develop' into 2921-tags-select2 2020-01-30 17:51:55 +00:00
Saria Hajjar
5879671971 Avoid overriding private attribute in super 2020-01-30 17:49:42 +00:00
Saria Hajjar
2375d66f75 Added TagFilterField to device components' filter forms 2020-01-30 17:45:03 +00:00
Jeremy Stretch
923c2728b3 Fixes #4056: Repair schema migration for Rack.outer_unit (from #3569) 2020-01-30 12:08:40 -05:00
Jeremy Stretch
4ba2579936 Closes #4051: Disable the makemigrations management command 2020-01-30 10:12:53 -05:00
Jeremy Stretch
03087e9d01 Fixes #4049: Restore missing tags field in IPAM service serializer 2020-01-29 16:22:06 -05:00
Jeremy Stretch
eafeaab014 Add tests for invalid import data 2020-01-29 16:07:32 -05:00
Jeremy Stretch
c75315fda6 Extend CSV import test 2020-01-29 15:34:55 -05:00
Jeremy Stretch
193435b554 Enable CSV import for custom fields 2020-01-29 14:29:47 -05:00
Dan Sheppard
d30d79b4e3 Cleanup Imports 2020-01-29 12:55:19 -06:00
Dan Sheppard
23155551d1 Remove complex ordering for IP addresses
After review complex ordering does not appear to be required
2020-01-29 12:54:55 -06:00
Jeremy Stretch
e6b018909d Introduced CustomFieldModelCSVForm 2020-01-29 13:53:26 -05:00
Dan Sheppard
22228b58f1 #4034 - Create tests for addresses 2020-01-29 12:52:48 -06:00
Jeremy Stretch
35f2291edc Fix assignment of initial CustomField values when editing an object 2020-01-29 13:31:36 -05:00
Jeremy Stretch
c3f86456d6 Remove get_custom_fields_for_model() 2020-01-29 12:12:47 -05:00
Jeremy Stretch
585ea71d1a Move form field generation logic to CustomField class 2020-01-29 11:44:37 -05:00
Jeremy Stretch
9929a05bfe Update release notes 2020-01-29 11:00:46 -05:00
Jeremy Stretch
f12199dcb5 Rename and simplify CustomFieldChoiceField 2020-01-29 11:00:03 -05:00
Jeremy Stretch
bc7cf63958 Rename and refactor CustomFieldForm 2020-01-29 10:59:18 -05:00
Jeremy Stretch
db3b4505c1 Merge pull request #3885 from hSaria/568-csv-import-cf
Fixes #568: CSV import/export of custom fields
2020-01-29 10:11:40 -05:00
Jeremy Stretch
943c644dc9 Merge pull request #4037 from newlandk/patch-1
Fixes #4039: LDAP Documentation
2020-01-29 10:09:07 -05:00
Jeremy Stretch
e0d538ad31 Fixes #4043: Fix toggling of required fields in custom scripts 2020-01-29 09:40:17 -05:00
Jeremy Stretch
1849473469 Merge pull request #4023 from smutel/UpdateDoc
Fixes #4024: Update nginx documentation
2020-01-29 09:29:50 -05:00
Dan Sheppard
084a68f6d1 #4034 - Create tests for prefixes 2020-01-28 22:11:31 -06:00
Kevin Newland
6fefa3c7dd update ldap documentation
use new ldap cache configuration in documentation
https://github.com/netbox-community/netbox/blob/develop/netbox/netbox/settings.py#L360
2020-01-28 18:34:26 -06:00
Jeremy Stretch
4629cda9ad Post-release version bump 2020-01-28 16:42:33 -05:00
Jeremy Stretch
3143f75a38 Merge pull request #4035 from netbox-community/develop
Release v2.7.3
2020-01-28 16:39:09 -05:00
Jeremy Stretch
be716a3345 Release v2.7.3 2020-01-28 16:33:55 -05:00
Jeremy Stretch
8de9f52151 Fixes #4033: Restore missing comments field label of various bulk edit forms 2020-01-28 16:09:10 -05:00
Jeremy Stretch
0a11fc1221 Fixes #4030: Fix exception when setting interfaces to tagged mode in bulk 2020-01-28 14:19:29 -05:00
Jeremy Stretch
ede576a2ae Changelog for #4022 2020-01-28 13:55:44 -05:00
Jeremy Stretch
12cf69f7e1 Merge pull request #4022 from hSaria/4010-interface-ip-filter
Fixes #4010: Fixes IP addresses table when filtering interfaces
2020-01-28 13:54:21 -05:00
Jeremy Stretch
2a4ccae113 Merge pull request #4031 from kobayashi/3978-add-vrf-filter
Fixes #3978: VRF filtering for NAT IP search
2020-01-28 13:46:54 -05:00
Jeremy Stretch
77292050d4 Changelog for #4025 2020-01-28 13:38:03 -05:00
Jeremy Stretch
e7ef142620 Merge pull request #4026 from hSaria/4025-cable-status-class
Fixes #4025: Cable status class
2020-01-28 13:34:46 -05:00
Jeremy Stretch
07d8476cf5 Merge pull request #4032 from netbox-community/4027-ipaddress-migration
Fixes #4027: Repair schema migration for IP addresses with DHCP status
2020-01-28 13:32:17 -05:00
Jeremy Stretch
9b9e568446 Fixes #4027: Repair schema migration for #3569 to convert IP addresses with DHCP status 2020-01-28 12:49:00 -05:00
Saria Hajjar
8849f4b0a5 Added cluster groups and clusters to serializers 2020-01-28 17:30:26 +00:00
kobayashi
3c5346f60a Fixes #3978: VRF filtering for NAT IP search 2020-01-28 10:22:28 -05:00
Jeremy Stretch
8d547e9906 Fixes #4028: Correct URL patterns to match Unicode characters in tag slugs 2020-01-28 09:47:33 -05:00
Saria Hajjar
720bd87292 Fixed interface mark connected/planned buttons 2020-01-27 22:56:25 +00:00
Saria Hajjar
8306976b3e Removed erroneous double-space 2020-01-27 22:49:36 +00:00
Saria Hajjar
3bce8e9716 Fixes #4025: Cable status class 2020-01-27 22:44:38 +00:00
Jeremy Stretch
9c4f1d5795 Changelog for #3338 2020-01-27 17:24:00 -05:00
Jeremy Stretch
93fa00b673 #3338: Prefetch termination devices to avoid extra database queries 2020-01-27 17:22:31 -05:00
Jeremy Stretch
49a6332d37 Merge pull request #4012 from hSaria/3338-api-circuit-term
Fixes #3338: Added termination A and Z to the circuit
2020-01-27 17:14:40 -05:00
Saria Hajjar
5c5b9c95aa Interface selector restricted to only interface 2020-01-27 22:07:42 +00:00
Jeremy Stretch
7abcc7acaa Merge pull request #3993 from hSaria/3935-swagger-default-info
Fixes #3935: Swagger DEFAULT_INFO
2020-01-27 16:58:03 -05:00
Saria Hajjar
d0f127e575 Fixes #3338: Added termination A and Z to the circuit 2020-01-27 21:53:10 +00:00
Samuel Mutel
73b35e72d8 Update nginx documentation 2020-01-27 21:10:10 +01:00
Jeremy Stretch
00b50f9c65 Remove obsolete constants 2020-01-27 12:34:52 -05:00
Saria Hajjar
46d0e88da3 Fixes #4010: Fixes IP addresses table when filtering interfaces 2020-01-27 15:49:15 +00:00
Jeremy Stretch
1901f63b4c Update changelog 2020-01-27 09:45:18 -05:00
Jeremy Stretch
2662bd0ad8 Merge pull request #4017 from hSaria/4016-duplicate-tenant-field
Fixes #4016: Removed duplicate tenant field for cluster edit form
2020-01-27 09:36:36 -05:00
Jeremy Stretch
27d70b6b51 Merge pull request #4021 from hellerve/veit/fix-4019
SVG Elevation: Add borders on the rear of devices as well
2020-01-27 09:32:53 -05:00
hellerve
011280b0bf dcim: add borders on the rear of devices as well 2020-01-27 13:13:07 +01:00
Saria Hajjar
4e4a05d3b9 Fixes #4016: Removed duplicate tenant field for cluster edit form 2020-01-26 12:52:18 +00:00
Saria Hajjar
4abd3866ab Fixes #3886: Config context cluster (group) 2020-01-26 10:53:58 +00:00
Saria Hajjar
7cfdc5188c Corrected ConfigContext data 2020-01-25 17:55:01 +00:00
Saria Hajjar
265d5c87e7 Format for local and source contexts 2020-01-25 16:12:37 +00:00
Saria Hajjar
724d3b8894 Fixes #3313: YAML-format the config context in the GUI 2020-01-25 15:56:24 +00:00
Saria Hajjar
8ec0ad96bd Formatting 2020-01-24 22:20:41 +00:00
Saria Hajjar
c22024b618 Added CSV import test 2020-01-24 22:15:09 +00:00
Jeremy Stretch
7a548e806d Merge pull request #4009 from netbox-community/4006-remove-fixtures
Closes #4006: remove test fixtures
2020-01-24 16:36:41 -05:00
Jeremy Stretch
47962ea732 Adapt form tests to work without fixture data 2020-01-24 16:30:43 -05:00
Jeremy Stretch
eb4c2e5d7f Remove obsolete fixtures files 2020-01-24 16:29:23 -05:00
hSaria
ca035a72bd Merge branch 'develop' into 2921-tags-select2 2020-01-24 20:56:36 +00:00
Jeremy Stretch
a13bddde58 Refactor prefix and IP mask length choice generation to reference constants 2020-01-24 15:50:45 -05:00
Jeremy Stretch
66330418cb Remove obsolete IP_FAMILY_CHOICES constant 2020-01-24 15:40:03 -05:00
Jeremy Stretch
151943bfbc Merge pull request #4007 from netbox-community/3880-use-constants
Closes #3880: Define constants for arbitrary values
2020-01-24 15:29:38 -05:00
Jeremy Stretch
35cbee5107 Fixes #4008: Toggle rack elevation face using front/rear strings 2020-01-24 15:28:15 -05:00
Jeremy Stretch
c6473d654d Add explanatory text for constants 2020-01-24 15:03:38 -05:00
Jeremy Stretch
096814dc33 #3880: Define constants for arbitrary values 2020-01-24 14:42:57 -05:00
Jeremy Stretch
45b66b174c Merge pull request #3955 from kobayashi/3950-not-retain-device-type
Fixes: #3950 "Create and Add Another" does retain device type
2020-01-24 13:49:46 -05:00
Jeremy Stretch
0ec091ffe1 Merge branch 'develop' into 3950-not-retain-device-type 2020-01-24 13:49:30 -05:00
Jeremy Stretch
f24e7652a8 Add changelog for #3982 2020-01-24 12:10:38 -05:00
Jeremy Stretch
9f58c27fcf Merge pull request #4002 from hellerve/veit/fix-3982
Read reserved tooltip on rack elevations
2020-01-24 12:09:39 -05:00
Jeremy Stretch
d3463b596a Closes #4005: Include timezone context in webhook timestamps 2020-01-24 12:00:24 -05:00
kobayashi
66d5cc47a5 Fixes #3950: Cloned Device Form does not retain device type 2020-01-24 03:30:24 -05:00
kobayashi
9694bacb69 3950 not retain device type 2020-01-24 03:13:50 -05:00
hellerve
fcba2baf42 dcim: fix #3982 by readding reserved tooltip 2020-01-24 08:45:55 +01:00
Jeremy Stretch
629712142f Fixes #3999: Do not filter child results by null if non-required parent fields are blank 2020-01-23 17:11:45 -05:00
Jeremy Stretch
cdecf93f00 Add tests for ChoiceSet 2020-01-23 16:19:34 -05:00
Jeremy Stretch
fe402331f2 Handle grouped choices when returning ChoiceSet values 2020-01-23 16:16:52 -05:00
Jeremy Stretch
fcbbb36afc Add tests for home and search views 2020-01-23 15:41:09 -05:00
hSaria
6ce38ffa0f Merge branch 'develop' into 3995-navbar-overflow 2020-01-23 20:36:35 +00:00
Saria Hajjar
09faaff849 Fixes #3995: Navbar scroll when overflowing 2020-01-23 20:34:06 +00:00
hSaria
06398a9ac6 Merge branch 'develop' into 568-csv-import-cf 2020-01-23 20:27:07 +00:00
Saria Hajjar
bed08a7b07 Use model's get_custom_fields 2020-01-23 20:26:21 +00:00
Jeremy Stretch
2e69037c29 Closes #3952: Add test for webhooks_worker; introduce generate_signature() 2020-01-23 15:05:27 -05:00
Saria Hajjar
8f86244b4f Cleaned the CustomField choice field 2020-01-23 18:54:37 +00:00
Saria Hajjar
0a5eecd0e3 Explicitly use the value of the choice, instead of relying on __str__ 2020-01-23 17:37:51 +00:00
Saria Hajjar
0ab19d723d Moved the header join logic after the custom fields are added 2020-01-23 17:18:58 +00:00
Saria Hajjar
9128435113 Removed CustomFieldForm class from models without custom fields 2020-01-23 17:03:14 +00:00
Saria Hajjar
1b26afdfbb Fixes #3935: Swagger DEFAULT_INFO 2020-01-23 14:26:04 +00:00
Jeremy Stretch
7b517abdb6 Fixes #3989: Correct HTTP content type assignment for webhooks 2020-01-22 20:33:57 -05:00
Jeremy Stretch
2445d1896b Merge pull request #3988 from netbox-community/3509-ipaddress-script-vars
Closes #3509: Add IP address vars for custom scripts
2020-01-22 17:56:00 -05:00
Jeremy Stretch
72d1fe0cd7 Changelog for #3509 2020-01-22 17:49:03 -05:00
Jeremy Stretch
b7e71f9f39 Add tests for IP address vars 2020-01-22 17:48:03 -05:00
Jeremy Stretch
f41564b578 Introduce IPAddressVar and IPAddressWithMaskVar 2020-01-22 17:16:40 -05:00
Jeremy Stretch
aa56c020ab Move prefix_validator() to ipam.validators 2020-01-22 16:33:34 -05:00
Jeremy Stretch
ba6df87d10 Move min/max prefix length validators to ipam.validators 2020-01-22 16:26:06 -05:00
Jeremy Stretch
5e7fbc4e42 Merge pull request #3987 from netbox-community/3310-cableform-initial-data
Closes #3310: Pre-select site/rack for B side when creating a new cable
2020-01-22 16:14:17 -05:00
Jeremy Stretch
f826e15603 Closes #3310: Pre-select site/rack for B side when creating a new cable 2020-01-22 16:07:09 -05:00
Jeremy Stretch
b7dea5a9f7 Fixes #3983: Permit the creation of multiple unnamed devices 2020-01-22 09:26:49 -05:00
Jeremy Stretch
ddd9f86031 Add tests for rack elevation API endpoint 2020-01-21 17:36:38 -05:00
Jeremy Stretch
1c13a79961 Suppress extraneous test output 2020-01-21 17:23:50 -05:00
Jeremy Stretch
03436b729d Add test for device graphs API endpoint 2020-01-21 17:11:26 -05:00
Jeremy Stretch
d123664503 Add tests for front/rear port API endpoints 2020-01-21 17:00:30 -05:00
hSaria
bdfead6265 Merge branch 'develop' into 568-csv-import-cf 2020-01-21 21:30:38 +00:00
hSaria
77c8bcef6d Merge branch 'develop' into 2921-tags-select2 2020-01-21 21:29:21 +00:00
Jeremy Stretch
10917123fd Add tests for cable tracing endpoints 2020-01-21 16:24:03 -05:00
Jeremy Stretch
b06bed368b Post-release version bump 2020-01-21 15:13:49 -05:00
hSaria
b11224a8b4 Merge branch 'develop' into 568-csv-import-cf 2020-01-17 11:47:01 +00:00
hSaria
8b02cd47fb Merge branch 'develop' into 2921-tags-select2 2020-01-17 11:45:13 +00:00
hSaria
c0a3285b8b Merge branch 'develop' into 568-csv-import-cf 2020-01-16 22:47:10 +00:00
hSaria
42962db263 Merge branch 'develop' into 2921-tags-select2 2020-01-16 21:52:14 +00:00
Saria Hajjar
e05cecb481 Moved into v2.7.1 2020-01-16 21:51:01 +00:00
Saria Hajjar
9f68f8d1a6 Update component CSV forms 2020-01-16 16:07:24 +00:00
Saria Hajjar
a2d5aca1d9 Moved changelog to v2.7 2020-01-16 16:05:45 +00:00
Saria Hajjar
89e6de3652 Merge branch 'develop' into 568-csv-import-cf 2020-01-16 16:05:01 +00:00
Saria Hajjar
26ebed0182 Removed legacy work regarding inc/tags_panel.html 2020-01-16 15:42:31 +00:00
Saria Hajjar
2c0f321456 Merge branch '2921-tags-select2' of https://github.com/hSaria/netbox into 2921-tags-select2 2020-01-16 15:34:56 +00:00
Saria Hajjar
8f91e9b079 Added #2921 changelog 2020-01-16 15:34:11 +00:00
Saria Hajjar
2949bfaaa7 Merge branch 'develop' into 2921-tags-select2 2020-01-16 15:33:42 +00:00
hSaria
a7e87eeadc Merge branch 'develop' into 2921-tags-select2 2020-01-15 09:30:51 +00:00
Saria Hajjar
e10333bf2b Fetch choices during form initialization 2020-01-14 08:22:27 +00:00
hSaria
d075bf5882 Merge branch 'develop' into 568-csv-import-cf 2020-01-13 21:17:29 +00:00
hSaria
83ee83142a Merge branch 'develop' into 2921-tags-select2 2020-01-13 20:17:34 +00:00
Saria Hajjar
865e3e7c9f Updated changelog 2020-01-13 20:17:47 +00:00
Saria Hajjar
2f28dec891 Tag filter field for filter forms 2020-01-13 20:16:13 +00:00
Saria Hajjar
a8d9fe799b Removed tags filter field from view 2020-01-13 19:06:05 +00:00
Saria Hajjar
834fd408bd Fixes #2921: Replace tags filter with Select2 widget 2020-01-11 15:18:27 +00:00
Saria Hajjar
de1355e6bc Changelog #568 2020-01-10 15:00:57 +00:00
Saria Hajjar
37322fc100 Fixed import choice name 2020-01-10 14:58:15 +00:00
Saria Hajjar
f1d5e28f13 CSV import/export custom fields 2020-01-10 14:26:39 +00:00
191 changed files with 7589 additions and 9910 deletions

View File

@@ -5,7 +5,9 @@ about: Report a reproducible bug in the current release of NetBox
---
<!--
NOTE: This form is only for reproducible bugs. If you need assistance with
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for reproducible bugs. If you need assistance with
NetBox installation, or if you have a general question, DO NOT open an
issue. Instead, post to our mailing list:
@@ -16,8 +18,8 @@ about: Report a reproducible bug in the current release of NetBox
before submitting a bug report.
-->
### Environment
* Python version: <!-- Example: 3.5.4 -->
* NetBox version: <!-- Example: 2.5.2 -->
* Python version: <!-- Example: 3.6.9 -->
* NetBox version: <!-- Example: 2.7.3 -->
<!--
Describe in detail the exact steps that someone else can take to reproduce

View File

@@ -5,6 +5,8 @@ about: Suggest an addition or modification to the NetBox documentation
---
<!--
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
Please indicate the nature of the change by placing an X in one of the
boxes below.
-->
@@ -14,5 +16,13 @@ about: Suggest an addition or modification to the NetBox documentation
[ ] Deprecation
[ ] Cleanup (formatting, typos, etc.)
### Area
[ ] Installation instructions
[ ] Configuration parameters
[ ] Functionality/features
[ ] REST API
[ ] Administration/development
[ ] Other
<!-- Describe the proposed change(s). -->
### Proposed Changes

View File

@@ -5,7 +5,9 @@ about: Propose a new NetBox feature or enhancement
---
<!--
NOTE: This form is only for proposing specific new features or enhancements.
NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.
This form is only for proposing specific new features or enhancements.
If you have a general idea or question, please post to our mailing list
instead of opening an issue:
@@ -19,8 +21,8 @@ about: Propose a new NetBox feature or enhancement
before submitting a bug report.
-->
### Environment
* Python version: <!-- Example: 3.5.4 -->
* NetBox version: <!-- Example: 2.3.6 -->
* Python version: <!-- Example: 3.6.9 -->
* NetBox version: <!-- Example: 2.7.3 -->
<!--
Describe in detail the new functionality you are proposing. Include any

View File

@@ -1,14 +1,13 @@
---
name: 🏡 Housekeeping
about: A change pertaining to the codebase itself
about: A change pertaining to the codebase itself (developers only)
---
<!--
NOTE: This type of issue should be opened only by those reasonably familiar
with NetBox's code base and interested in contributing to its development.
Describe the proposed change(s) in detail.
NOTE: This template is for use by maintainers only. Please do not submit
an issue using this template unless you have been specifically asked to
do so.
-->
### Proposed Changes

3
.github/stale.yml vendored
View File

@@ -1,5 +1,8 @@
# Configuration for Stale (https://github.com/apps/stale)
# Pull requests are exempt from being marked as stale
only: issues
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14

View File

@@ -124,7 +124,7 @@ Arbitrary text of any length. Renders as multi-line text input field.
Stored a numeric integer. Options include:
* `min_value:` - Minimum value
* `min_value` - Minimum value
* `max_value` - Maximum value
### BooleanVar
@@ -158,18 +158,30 @@ A NetBox object. The list of available objects is defined by the queryset parame
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
### IPAddressVar
An IPv4 or IPv6 address, without a mask. Returns a `netaddr.IPAddress` object.
### IPAddressWithMaskVar
An IPv4 or IPv6 address with a mask. Returns a `netaddr.IPNetwork` object which includes the mask.
### IPNetworkVar
An IPv4 or IPv6 network with a mask.
An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two attributes are available to validate the provided mask:
* `min_prefix_length` - Minimum length of the mask (default: none)
* `max_prefix_length` - Maximum length of the mask (default: none)
### Default Options
All variables support the following default options:
* `label` - The name of the form field
* `description` - A brief description of the field
* `default` - The field's default value
* `description` - A brief description of the field
* `label` - The name of the form field
* `required` - Indicates whether the field is mandatory (default: true)
* `widget` - The class of form widget to use (see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/forms/widgets/))
## Example

View File

@@ -90,6 +90,14 @@ This setting enables debugging. This should be done only during development or t
---
## DEVELOPER
Default: False
This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base.
---
## EMAIL
In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
@@ -101,6 +109,20 @@ In order to send email, NetBox needs an email server configured. The following i
* TIMEOUT - Amount of time to wait for a connection (seconds)
* FROM_EMAIL - Sender address for emails sent by NetBox
Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
```
# python ./manage.py nbshell
>>> from django.core.mail import send_mail
>>> send_mail(
'Test Email Subject',
'Test Email Body',
'noreply-netbox@example.com',
['users@example.com'],
fail_silently=False
)
```
---
## EXEMPT_VIEW_PERMISSIONS
@@ -127,7 +149,7 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
---
# ENFORCE_GLOBAL_UNIQUE
## ENFORCE_GLOBAL_UNIQUE
Default: False

View File

@@ -21,7 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
* `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)).
* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (150-300 is recommended)
Example:
@@ -36,6 +36,9 @@ DATABASE = {
}
```
!!! note
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
---
## REDIS
@@ -85,6 +88,48 @@ REDIS = {
It is highly recommended to keep the webhook and cache databases separate. Using the same database number on the
same Redis instance for both may result in webhook processing data being lost during cache flushing events.
### Using Redis Sentinel
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from
above and the addition of two new keys.
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to
Example:
```python
REDIS = {
'webhooks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
},
'caching': {
'SENTINELS': [
('mysentinel.redis.example.com', 6379),
('othersentinel.redis.example.com', 6379)
],
'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
}
```
!!! note:
It is possible to have only one or the other Redis configurations to use Sentinel functionality. It is possible
for example to have the webhook use sentinel via `HOST`/`PORT` and for caching to use Sentinel via
`SENTINELS`/`SENTINEL_SERVICE`.
---
## SECRET_KEY

View File

@@ -32,7 +32,7 @@ pycodestyle --ignore=W504,E501 netbox/
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
If there's a strong case for introducing a new depdency, it must meet the following criteria:
If there's a strong case for introducing a new dependency, it must meet the following criteria:
* Its complete source code must be published and freely accessible without registration.
* Its license must be conducive to inclusion in an open source project.
@@ -45,10 +45,18 @@ When adding a new dependency, a short description of the package and the URL of
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically gives us several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
* Every model should have a docstring. Every custom method should include an expalantion of its function.
* Every model should have a docstring. Every custom method should include an explanation of its function.
* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.
## Branding
* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation.
* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size.

View File

@@ -29,7 +29,7 @@ server {
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -107,9 +107,10 @@ Install gunicorn:
# pip3 install gunicorn
```
Copy `contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.
```no-highlight
# cd /opt/netbox
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
```

View File

@@ -110,8 +110,8 @@ AUTH_LDAP_USER_FLAGS_BY_GROUP = {
AUTH_LDAP_FIND_GROUP_PERMS = True
# Cache groups for one hour to reduce LDAP traffic
AUTH_LDAP_CACHE_GROUPS = True
AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
AUTH_LDAP_CACHE_TIMEOUT = 3600
```
* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.

View File

@@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
2. [NetBox components](2-netbox.md)
3. [HTTP dameon](3-http-daemon.md)
3. [HTTP daemon](3-http-daemon.md)
4. [LDAP authentication](4-ldap.md) (optional)
# Upgrading

View File

@@ -12,84 +12,19 @@ Migration is not required, as supervisord will still continue to function.
### systemd configuration:
Copy or link contrib/netbox.service and contrib/netbox-rq.service to /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service
We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
```no-highlight
# cp contrib/netbox.service /etc/systemd/system/netbox.service
# cp contrib/netbox-rq.service /etc/systemd/system/netbox-rq.service
# cp contrib/*.service /etc/systemd/system/
```
Edit /etc/systemd/system/netbox.service and /etc/systemd/system/netbox-rq.service. Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`). If using CentOS/RHEL. Change the username from `www-data` to `nginx` or `apache`:
!!! note
These service files assume that gunicorn is installed at `/usr/local/bin/gunicorn`. If the output of `which gunicorn` indicates a different path, you'll need to correct the `ExecStart` path in both files.
```no-highlight
/usr/local/bin/gunicorn --pid ${PidPath} --pythonpath ${WorkingDirectory}/netbox --config ${ConfigPath} netbox.wsgi
```
!!! note
You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data" or any number of other usernames.
```no-highlight
User=www-data
Group=www-data
```
Copy contrib/netbox.env to /etc/sysconfig/netbox.env
```no-highlight
# cp contrib/netbox.env /etc/sysconfig/netbox.env
```
Edit /etc/sysconfig/netbox.env and change the settings as required. Update the `WorkingDirectory` variable if needed.
```no-highlight
# Name is the Process Name
#
Name = 'Netbox'
# ConfigPath is the path to the gunicorn config file.
#
ConfigPath=/opt/netbox/gunicorn.conf
# WorkingDirectory is the Working Directory for Netbox.
#
WorkingDirectory=/opt/netbox/
# PidPath is the path to the pid for the netbox WSGI
#
PidPath=/var/run/netbox.pid
```
Copy contrib/gunicorn.conf to gunicorn.conf
```no-highlight
# cp contrib/gunicorn.conf to gunicorn.conf
```
Edit gunicorn.conf and change the settings as required.
```
# Bind is the ip and port that the Netbox WSGI should bind to
#
bind='127.0.0.1:8001'
# Workers is the number of workers that GUnicorn should spawn.
# Workers should be: cores * 2 + 1. So if you have 8 cores, it would be 17.
#
workers=3
# Threads
# The number of threads for handling requests
#
threads=3
# Timeout is the timeout between gunicorn receiving a request and returning a response (or failing with a 500 error)
#
timeout=120
# ErrorLog
# ErrorLog is the logfile for the ErrorLog
#
errorlog='/opt/netbox/netbox.log'
```
Finally, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl daemon-reload
@@ -98,3 +33,25 @@ Finally, start the `netbox` and `netbox-rq` services and enable them to initiate
# systemctl enable netbox.service
# systemctl enable netbox-rq.service
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
```
# systemctl status netbox.service
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
...
```
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.

View File

@@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui
```no-highlight
# sudo systemctl restart netbox
# sudo systemctl restart netbox-rqworker
# sudo systemctl restart netbox-rq
```
!!! note

View File

@@ -1,3 +1,99 @@
# v2.7.6 (2020-02-13)
## Bug Fixes
* [#4166](https://github.com/netbox-community/netbox/issues/4166) - Fix schema migrations to enforce maximum character length for naturalized fields
---
# v2.7.5 (2020-02-13)
**Note:** This release includes several database schema migrations that calculate and store copies of names for certain objects to improve natural ordering performance (see [#3799](https://github.com/netbox-community/netbox/issues/3799)). These migrations may take a few minutes to run if you have a very large number of objects defined in NetBox.
## Enhancements
* [#3766](https://github.com/netbox-community/netbox/issues/3766) - Allow custom script authors to specify the form widget for each variable
* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components
* [#3984](https://github.com/netbox-community/netbox/issues/3984) - Add support for Redis Sentinel
* [#3986](https://github.com/netbox-community/netbox/issues/3986) - Include position numbers in SVG image when rendering rack elevations
* [#4093](https://github.com/netbox-community/netbox/issues/4093) - Add more status choices for virtual machines
* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views
* [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components
* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views
* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components
## Bug Fixes
* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IP addresses by multiple devices
* [#3995](https://github.com/netbox-community/netbox/issues/3995) - Make dropdown menus in the navigation bar scrollable on small screens
* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests
* [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional
* [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view
* [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form
* [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list
* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms
* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type
* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit
* [#4138](https://github.com/netbox-community/netbox/issues/4138) - Restore device bay counts in rack elevation diagrams
* [#4146](https://github.com/netbox-community/netbox/issues/4146) - Fix enforcement of secret role assignment for secret decryption
* [#4150](https://github.com/netbox-community/netbox/issues/4150) - Correct YAML rendering of config contexts
* [#4159](https://github.com/netbox-community/netbox/issues/4159) - Fix implementation of Redis caching configuration
---
# v2.7.4 (2020-02-04)
## Enhancements
* [#568](https://github.com/netbox-community/netbox/issues/568) - Allow custom fields to be imported and exported using CSV
* [#2921](https://github.com/netbox-community/netbox/issues/2921) - Replace tags filter with Select2 widget
* [#3313](https://github.com/netbox-community/netbox/issues/3313) - Toggle config context display between JSON and YAML
* [#3886](https://github.com/netbox-community/netbox/issues/3886) - Enable assigning config contexts by cluster and cluster group
* [#4051](https://github.com/netbox-community/netbox/issues/4051) - Disable the `makemigrations` management command
## Bug Fixes
* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised)
* [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts
* [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer
* [#4052](https://github.com/netbox-community/netbox/issues/4052) - Fix error when bulk importing interfaces to virtual machines
* [#4056](https://github.com/netbox-community/netbox/issues/4056) - Repair schema migration for Rack.outer_unit (from #3569)
* [#4067](https://github.com/netbox-community/netbox/issues/4067) - Correct permission checked when creating a rack (vs. editing)
* [#4071](https://github.com/netbox-community/netbox/issues/4071) - Enforce "view tag" permission on individual tag view
* [#4079](https://github.com/netbox-community/netbox/issues/4079) - Fix assignment of power panel when bulk editing power feeds
* [#4084](https://github.com/netbox-community/netbox/issues/4084) - Fix exception when creating an interface with tagged VLANs
---
# v2.7.3 (2020-01-28)
## Enhancements
* [#3310](https://github.com/netbox-community/netbox/issues/3310) - Pre-select site/rack for B side when creating a new cable
* [#3338](https://github.com/netbox-community/netbox/issues/3338) - Include circuit terminations in API representation of circuits
* [#3509](https://github.com/netbox-community/netbox/issues/3509) - Add IP address variables for custom scripts
* [#3978](https://github.com/netbox-community/netbox/issues/3978) - Add VRF filtering to search NAT IP
* [#4005](https://github.com/netbox-community/netbox/issues/4005) - Include timezone context in webhook timestamps
## Bug Fixes
* [#3950](https://github.com/netbox-community/netbox/issues/3950) - Automatically select parent manufacturer when specifying initial device type during device creation
* [#3982](https://github.com/netbox-community/netbox/issues/3982) - Restore tooltip for reservations on rack elevations
* [#3983](https://github.com/netbox-community/netbox/issues/3983) - Permit the creation of multiple unnamed devices
* [#3989](https://github.com/netbox-community/netbox/issues/3989) - Correct HTTP content type assignment for webhooks
* [#3999](https://github.com/netbox-community/netbox/issues/3999) - Do not filter child results by null if non-required parent fields are blank
* [#4008](https://github.com/netbox-community/netbox/issues/4008) - Toggle rack elevation face using front/rear strings
* [#4017](https://github.com/netbox-community/netbox/issues/4017) - Remove redundant tenant field from cluster form
* [#4019](https://github.com/netbox-community/netbox/issues/4019) - Restore border around background devices in rack elevations
* [#4022](https://github.com/netbox-community/netbox/issues/4022) - Fix display of assigned IPs when filtering device interfaces
* [#4025](https://github.com/netbox-community/netbox/issues/4025) - Correct display of cable status (various places)
* [#4027](https://github.com/netbox-community/netbox/issues/4027) - Repair schema migration for #3569 to convert IP addresses with DHCP status
* [#4028](https://github.com/netbox-community/netbox/issues/4028) - Correct URL patterns to match Unicode characters in tag slugs
* [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when setting interfaces to tagged mode in bulk
* [#4033](https://github.com/netbox-community/netbox/issues/4033) - Restore missing comments field label of various bulk edit forms
---
# v2.7.2 (2020-01-21)
## Enhancements

View File

@@ -41,7 +41,6 @@ pages:
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md'
- Topology Maps: 'additional-features/topology-maps.md'
- Webhooks: 'additional-features/webhooks.md'
- Administration:
- Replicating NetBox: 'administration/replicating-netbox.md'

View File

@@ -3,11 +3,11 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import *
@@ -39,18 +39,30 @@ class CircuitTypeSerializer(ValidatedModelSerializer):
fields = ['id', 'name', 'slug', 'description', 'circuit_count']
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer()
connected_endpoint = NestedInterfaceSerializer()
class Meta:
model = CircuitTermination
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Circuit
fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
router.APIRootView = CircuitsRootView
# Field choices
router.register(r'_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.CircuitsFieldChoicesViewSet, basename='field-choice')
# Providers
router.register(r'providers', views.ProviderViewSet)
router.register('providers', views.ProviderViewSet)
# Circuits
router.register(r'circuit-types', views.CircuitTypeViewSet)
router.register(r'circuits', views.CircuitViewSet)
router.register(r'circuit-terminations', views.CircuitTerminationViewSet)
router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet)
app_name = 'circuits-api'
urlpatterns = router.urls

View File

@@ -62,7 +62,9 @@ class CircuitTypeViewSet(ModelViewSet):
#
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
queryset = Circuit.objects.prefetch_related(
'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet

View File

@@ -2,12 +2,15 @@ from django import forms
from taggit.forms import TagField
from dcim.models import Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2,
StaticSelect2Multiple, TagFilterField,
)
from .choices import CircuitStatusChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -17,7 +20,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
class ProviderForm(BootstrapMixin, CustomFieldForm):
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = TagField(
@@ -46,7 +49,7 @@ class ProviderForm(BootstrapMixin, CustomFieldForm):
}
class ProviderCSVForm(forms.ModelForm):
class ProviderCSVForm(CustomFieldModelCSVForm):
slug = SlugField()
class Meta:
@@ -89,7 +92,8 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdi
label='Admin contact'
)
comments = CommentField(
widget=SmallTextarea()
widget=SmallTextarea,
label='Comments'
)
class Meta:
@@ -104,7 +108,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -116,9 +120,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
@@ -128,6 +133,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='ASN'
)
tag = TagFilterField(model)
#
@@ -159,7 +165,19 @@ class CircuitTypeCSVForm(forms.ModelForm):
# Circuits
#
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
widget=APISelect(
api_url="/api/circuits/providers/"
)
)
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
widget=APISelect(
api_url="/api/circuits/circuit-types/"
)
)
comments = CommentField()
tags = TagField(
required=False
@@ -176,18 +194,12 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'commit_rate': "Committed rate",
}
widgets = {
'provider': APISelect(
api_url="/api/circuits/providers/"
),
'type': APISelect(
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(),
'install_date': DatePicker(),
}
class CircuitCSVForm(forms.ModelForm):
class CircuitCSVForm(CustomFieldModelCSVForm):
provider = forms.ModelChoiceField(
queryset=Provider.objects.all(),
to_field_name='name',
@@ -231,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
queryset=Circuit.objects.all(),
widget=forms.MultipleHiddenInput
)
type = forms.ModelChoiceField(
type = DynamicModelChoiceField(
queryset=CircuitType.objects.all(),
required=False,
widget=APISelect(
api_url="/api/circuits/circuit-types/"
)
)
provider = forms.ModelChoiceField(
provider = DynamicModelChoiceField(
queryset=Provider.objects.all(),
required=False,
widget=APISelect(
@@ -251,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
initial='',
widget=StaticSelect2()
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -286,17 +298,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
label='Search'
)
type = FilterChoiceField(
type = DynamicModelMultipleChoiceField(
queryset=CircuitType.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/circuits/circuit-types/",
value_field="slug",
)
)
provider = FilterChoiceField(
provider = DynamicModelMultipleChoiceField(
queryset=Provider.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/circuits/providers/",
value_field="slug",
@@ -307,7 +321,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False,
widget=StaticSelect2Multiple()
)
region = forms.ModelMultipleChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -319,9 +333,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
@@ -332,6 +347,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
min_value=0,
label='Commit rate (Kbps)'
)
tag = TagFilterField(model)
#

View File

@@ -1,23 +1,15 @@
import urllib.parse
from django.test import Client, TestCase
from django.urls import reverse
import datetime
from circuits.choices import *
from circuits.models import Circuit, CircuitType, Provider
from utilities.testing import create_test_user
from utilities.testing import ViewTestCases
class ProviderTestCase(TestCase):
class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Provider
def setUp(self):
user = create_test_user(
permissions=[
'circuits.view_provider',
'circuits.add_provider',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
Provider.objects.bulk_create([
Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -25,48 +17,40 @@ class ProviderTestCase(TestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003),
])
def test_provider_list(self):
url = reverse('circuits:provider_list')
params = {
"q": "test",
cls.form_data = {
'name': 'Provider X',
'slug': 'provider-x',
'asn': 65123,
'account': '1234',
'portal_url': 'http://example.com/portal',
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_provider(self):
provider = Provider.objects.first()
response = self.client.get(provider.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_provider_import(self):
csv_data = (
cls.csv_data = (
"name,slug",
"Provider 4,provider-4",
"Provider 5,provider-5",
"Provider 6,provider-6",
)
response = self.client.post(reverse('circuits:provider_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(Provider.objects.count(), 6)
cls.bulk_edit_data = {
'asn': 65009,
'account': '5678',
'portal_url': 'http://example.com/portal2',
'noc_contact': 'noc2@example.com',
'admin_contact': 'admin2@example.com',
'comments': 'New comments',
}
class CircuitTypeTestCase(TestCase):
class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = CircuitType
def setUp(self):
user = create_test_user(
permissions=[
'circuits.view_circuittype',
'circuits.add_circuittype',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
CircuitType.objects.bulk_create([
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -74,79 +58,71 @@ class CircuitTypeTestCase(TestCase):
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
])
def test_circuittype_list(self):
cls.form_data = {
'name': 'Circuit Type X',
'slug': 'circuit-type-x',
'description': 'A new circuit type',
}
url = reverse('circuits:circuittype_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_circuittype_import(self):
csv_data = (
cls.csv_data = (
"name,slug",
"Circuit Type 4,circuit-type-4",
"Circuit Type 5,circuit-type-5",
"Circuit Type 6,circuit-type-6",
)
response = self.client.post(reverse('circuits:circuittype_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(CircuitType.objects.count(), 6)
class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Circuit
@classmethod
def setUpTestData(cls):
class CircuitTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'circuits.view_circuit',
'circuits.add_circuit',
]
providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001),
Provider(name='Provider 2', slug='provider-2', asn=65002),
)
self.client = Client()
self.client.force_login(user)
Provider.objects.bulk_create(providers)
provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
provider.save()
circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
circuittype.save()
circuittypes = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
)
CircuitType.objects.bulk_create(circuittypes)
Circuit.objects.bulk_create([
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
Circuit(cid='Circuit 1', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 2', provider=providers[0], type=circuittypes[0]),
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
])
def test_circuit_list(self):
url = reverse('circuits:circuit_list')
params = {
"provider": Provider.objects.first().slug,
"type": CircuitType.objects.first().slug,
cls.form_data = {
'cid': 'Circuit X',
'provider': providers[1].pk,
'type': circuittypes[1].pk,
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'tenant': None,
'install_date': datetime.date(2020, 1, 1),
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_circuit(self):
circuit = Circuit.objects.first()
response = self.client.get(circuit.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_circuit_import(self):
csv_data = (
cls.csv_data = (
"cid,provider,type",
"Circuit 4,Provider 1,Circuit Type 1",
"Circuit 5,Provider 1,Circuit Type 1",
"Circuit 6,Provider 1,Circuit Type 1",
)
response = self.client.post(reverse('circuits:circuit_import'), {'csv': '\n'.join(csv_data)})
cls.bulk_edit_data = {
'provider': providers[1].pk,
'type': circuittypes[1].pk,
'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
'tenant': None,
'commit_rate': 2000,
'description': 'New description',
'comments': 'New comments',
self.assertEqual(response.status_code, 200)
self.assertEqual(Circuit.objects.count(), 6)
}

View File

@@ -9,42 +9,42 @@ app_name = 'circuits'
urlpatterns = [
# Providers
path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path('providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
path('providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path('providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path('providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
# Circuit types
path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path('circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations
path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path('circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
@@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True)
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
@@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
@@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
@@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
required=False
)
@@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
required=False
)
power_port = PowerPortTemplateSerializer(
@@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
allow_null=True
allow_blank=True,
required=False
)
class Meta:
@@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer()
rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True)
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False)
status = ChoiceField(choices=DeviceStatusChoices, required=False)
primary_ip = NestedIPAddressSerializer(read_only=True)
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
@@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
allow_blank=True,
required=False
)
power_port = NestedPowerPortSerializer(
@@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
)
feed_leg = ChoiceField(
choices=PowerOutletFeedLegChoices,
required=False,
allow_null=True
allow_blank=True,
required=False
)
cable = NestedCableSerializer(
read_only=True
@@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
allow_blank=True,
required=False
)
cable = NestedCableSerializer(read_only=True)
@@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer):
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CableStatusChoices, required=False)
length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
class Meta:
model = Cable

View File

@@ -15,65 +15,65 @@ router = routers.DefaultRouter()
router.APIRootView = DCIMRootView
# Field choices
router.register(r'_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.DCIMFieldChoicesViewSet, basename='field-choice')
# Sites
router.register(r'regions', views.RegionViewSet)
router.register(r'sites', views.SiteViewSet)
router.register('regions', views.RegionViewSet)
router.register('sites', views.SiteViewSet)
# Racks
router.register(r'rack-groups', views.RackGroupViewSet)
router.register(r'rack-roles', views.RackRoleViewSet)
router.register(r'racks', views.RackViewSet)
router.register(r'rack-reservations', views.RackReservationViewSet)
router.register('rack-groups', views.RackGroupViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
router.register('rack-reservations', views.RackReservationViewSet)
# Device types
router.register(r'manufacturers', views.ManufacturerViewSet)
router.register(r'device-types', views.DeviceTypeViewSet)
router.register('manufacturers', views.ManufacturerViewSet)
router.register('device-types', views.DeviceTypeViewSet)
# Device type components
router.register(r'console-port-templates', views.ConsolePortTemplateViewSet)
router.register(r'console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
router.register(r'power-port-templates', views.PowerPortTemplateViewSet)
router.register(r'power-outlet-templates', views.PowerOutletTemplateViewSet)
router.register(r'interface-templates', views.InterfaceTemplateViewSet)
router.register(r'front-port-templates', views.FrontPortTemplateViewSet)
router.register(r'rear-port-templates', views.RearPortTemplateViewSet)
router.register(r'device-bay-templates', views.DeviceBayTemplateViewSet)
router.register('console-port-templates', views.ConsolePortTemplateViewSet)
router.register('console-server-port-templates', views.ConsoleServerPortTemplateViewSet)
router.register('power-port-templates', views.PowerPortTemplateViewSet)
router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
router.register('interface-templates', views.InterfaceTemplateViewSet)
router.register('front-port-templates', views.FrontPortTemplateViewSet)
router.register('rear-port-templates', views.RearPortTemplateViewSet)
router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
# Devices
router.register(r'device-roles', views.DeviceRoleViewSet)
router.register(r'platforms', views.PlatformViewSet)
router.register(r'devices', views.DeviceViewSet)
router.register('device-roles', views.DeviceRoleViewSet)
router.register('platforms', views.PlatformViewSet)
router.register('devices', views.DeviceViewSet)
# Device components
router.register(r'console-ports', views.ConsolePortViewSet)
router.register(r'console-server-ports', views.ConsoleServerPortViewSet)
router.register(r'power-ports', views.PowerPortViewSet)
router.register(r'power-outlets', views.PowerOutletViewSet)
router.register(r'interfaces', views.InterfaceViewSet)
router.register(r'front-ports', views.FrontPortViewSet)
router.register(r'rear-ports', views.RearPortViewSet)
router.register(r'device-bays', views.DeviceBayViewSet)
router.register(r'inventory-items', views.InventoryItemViewSet)
router.register('console-ports', views.ConsolePortViewSet)
router.register('console-server-ports', views.ConsoleServerPortViewSet)
router.register('power-ports', views.PowerPortViewSet)
router.register('power-outlets', views.PowerOutletViewSet)
router.register('interfaces', views.InterfaceViewSet)
router.register('front-ports', views.FrontPortViewSet)
router.register('rear-ports', views.RearPortViewSet)
router.register('device-bays', views.DeviceBayViewSet)
router.register('inventory-items', views.InventoryItemViewSet)
# Connections
router.register(r'console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
router.register(r'power-connections', views.PowerConnectionViewSet, basename='powerconnections')
router.register(r'interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
router.register('console-connections', views.ConsoleConnectionViewSet, basename='consoleconnections')
router.register('power-connections', views.PowerConnectionViewSet, basename='powerconnections')
router.register('interface-connections', views.InterfaceConnectionViewSet, basename='interfaceconnections')
# Cables
router.register(r'cables', views.CableViewSet)
router.register('cables', views.CableViewSet)
# Virtual chassis
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
router.register('virtual-chassis', views.VirtualChassisViewSet)
# Power
router.register(r'power-panels', views.PowerPanelViewSet)
router.register(r'power-feeds', views.PowerFeedViewSet)
router.register('power-panels', views.PowerPanelViewSet)
router.register('power-feeds', views.PowerFeedViewSet)
# Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
router.register('connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
app_name = 'dcim-api'
urlpatterns = router.urls

View File

@@ -4,17 +4,32 @@ from .choices import InterfaceTypeChoices
#
# Rack elevation rendering
# Racks
#
RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
#
# Interface type groups
# RearPorts
#
REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64
#
# Interfaces
#
INTERFACE_MTU_MIN = 1
INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
VIRTUAL_IFACE_TYPES = [
InterfaceTypeChoices.TYPE_VIRTUAL,
InterfaceTypeChoices.TYPE_LAG,
@@ -31,6 +46,17 @@ WIRELESS_IFACE_TYPES = [
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
# PowerFeeds
#
POWERFEED_VOLTAGE_DEFAULT = 120
POWERFEED_AMPERAGE_DEFAULT = 20
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
#
# Cabling and connections
#

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,7 @@
from django.db.models import Manager, QuerySet
from django.db.models.expressions import RawSQL
from .constants import NONCONNECTABLE_IFACE_TYPES
# Regular expressions for parsing Interface names
TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')"
SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)"
SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)"
POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)"
SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)"
ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)"
CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)"
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
class InterfaceQuerySet(QuerySet):
@@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet):
class InterfaceManager(Manager):
def get_queryset(self):
"""
Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field
is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel,
and virtual circuit:
{type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc}
Components absent from the interface name are coalesced to zero or null. For example, an interface named
GigabitEthernet1/2/3 would be parsed as follows:
type = 'GigabitEthernet'
slot = 1
subslot = 2
position = 3
subposition = None
id = None
channel = 0
vc = 0
The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not
match any of the prescribed fields.
The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device
components.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = [
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk'
]
fields = {
'_type': RawSQL(TYPE_RE.format(sql_col), []),
'_id': RawSQL(ID_RE.format(sql_col), []),
'_slot': RawSQL(SLOT_RE.format(sql_col), []),
'_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []),
'_position': RawSQL(POSITION_RE.format(sql_col), []),
'_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []),
'_channel': RawSQL(CHANNEL_RE.format(sql_col), []),
'_vc': RawSQL(VC_RE.format(sql_col), []),
}
return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering)
return InterfaceQuerySet(self.model, using=self._db)

View File

@@ -37,7 +37,7 @@ def rack_status_to_slug(apps, schema_editor):
def rack_outer_unit_to_slug(apps, schema_editor):
Rack = apps.get_model('dcim', 'Rack')
for id, slug in RACK_DIMENSION_CHOICES:
Rack.objects.filter(status=str(id)).update(status=slug)
Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
class Migration(migrations.Migration):

View File

@@ -0,0 +1,27 @@
from django.db import migrations
RACK_DIMENSION_CHOICES = (
(1000, 'mm'),
(2000, 'in'),
)
def rack_outer_unit_to_slug(apps, schema_editor):
Rack = apps.get_model('dcim', 'Rack')
for id, slug in RACK_DIMENSION_CHOICES:
Rack.objects.filter(outer_unit=str(id)).update(outer_unit=slug)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0091_interface_type_other'),
]
operations = [
# Fixes a missed field migration from #3569; see bug #4056. The original migration has also been fixed,
# so this can be omitted when squashing in the future.
migrations.RunPython(
code=rack_outer_unit_to_slug
),
]

View File

@@ -0,0 +1,147 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_consoleports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsolePort'))
def naturalize_consoleserverports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsoleServerPort'))
def naturalize_powerports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerPort'))
def naturalize_poweroutlets(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerOutlet'))
def naturalize_frontports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'FrontPort'))
def naturalize_rearports(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'RearPort'))
def naturalize_devicebays(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'DeviceBay'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0092_fix_rack_outer_unit'),
]
operations = [
migrations.AlterModelOptions(
name='consoleport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='consoleserverport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='devicebay',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='frontport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='inventoryitem',
options={'ordering': ('device__id', 'parent__id', '_name')},
),
migrations.AlterModelOptions(
name='poweroutlet',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='powerport',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='rearport',
options={'ordering': ('device', '_name')},
),
migrations.AddField(
model_name='consoleport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_consoleserverports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_powerports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_poweroutlets,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_frontports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_rearports,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devicebays,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,138 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_consoleporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsolePortTemplate'))
def naturalize_consoleserverporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate'))
def naturalize_powerporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerPortTemplate'))
def naturalize_poweroutlettemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'PowerOutletTemplate'))
def naturalize_frontporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'FrontPortTemplate'))
def naturalize_rearporttemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'RearPortTemplate'))
def naturalize_devicebaytemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'DeviceBayTemplate'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0093_device_component_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='consoleporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='consoleserverporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='devicebaytemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='frontporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='poweroutlettemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='powerporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AlterModelOptions(
name='rearporttemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_consoleserverporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_powerporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_poweroutlettemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_frontporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_rearporttemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devicebaytemplates,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,70 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name, max_length=100))
def naturalize_sites(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Site'))
def naturalize_racks(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Rack'))
def naturalize_devices(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Device'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0094_device_component_template_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
),
migrations.AlterModelOptions(
name='rack',
options={'ordering': ('site', 'group', '_name', 'pk')},
),
migrations.AlterModelOptions(
name='site',
options={'ordering': ('_name',)},
),
migrations.AddField(
model_name='device',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_racks,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_devices,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,53 @@
from django.db import migrations
import utilities.fields
import utilities.ordering
def _update_model_names(model):
# Update each unique field value in bulk
for name in model.objects.values_list('name', flat=True).order_by('name').distinct():
model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name, max_length=100))
def naturalize_interfacetemplates(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'InterfaceTemplate'))
def naturalize_interfaces(apps, schema_editor):
_update_model_names(apps.get_model('dcim', 'Interface'))
class Migration(migrations.Migration):
dependencies = [
('dcim', '0095_primary_model_ordering'),
]
operations = [
migrations.AlterModelOptions(
name='interface',
options={'ordering': ('device', '_name')},
),
migrations.AlterModelOptions(
name='interfacetemplate',
options={'ordering': ('device_type', '_name')},
),
migrations.AddField(
model_name='interface',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,
reverse_code=migrations.RunPython.noop
),
migrations.RunPython(
code=naturalize_interfaces,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -22,8 +22,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from utilities.fields import ColorField
from utilities.managers import NaturalOrderingManager
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.utils import foreground_color, to_meters
from .device_component_templates import (
@@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel):
max_length=50,
unique=True
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
unique=True
)
@@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
}
class Meta:
ordering = ['name']
ordering = ('_name',)
def __str__(self):
return self.name
@@ -387,6 +389,10 @@ class RackElevationHelperMixin:
@staticmethod
def _draw_device_front(drawing, device, start, end, text):
name = str(device)
if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
color = device.device_role.color
link = drawing.add(
drawing.a(
@@ -401,11 +407,11 @@ class RackElevationHelperMixin:
))
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
hex_color = '#{}'.format(foreground_color(color))
link.add(drawing.text(str(device), insert=text, fill=hex_color))
link.add(drawing.text(str(name), insert=text, fill=hex_color))
@staticmethod
def _draw_device_rear(drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="blocked")
rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc('{}{} ({}U) {} {}'.format(
device.device_role, device.device_type.display_name,
device.device_type.u_height, device.asset_tag or '', device.serial or ''
@@ -414,7 +420,7 @@ class RackElevationHelperMixin:
drawing.add(drawing.text(str(device), insert=text))
@staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_):
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
link = drawing.add(
drawing.a(
href='{}?{}'.format(
@@ -424,14 +430,26 @@ class RackElevationHelperMixin:
target='_top'
)
)
if reservation:
link.set_desc('{}{} · {}'.format(
reservation.description, reservation.user, reservation.created
))
link.add(drawing.rect(start, end, class_=class_))
link.add(drawing.text("add device", insert=text, class_='add-device'))
def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height):
def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
drawing = self._setup_drawing(unit_width, unit_height * self.u_height)
drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
unit_cursor = 0
for ru in range(0, self.u_height):
start_y = ru * unit_height
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
unit = ru + 1 if self.desc_units else self.u_height - ru
drawing.add(
drawing.text(str(unit), position_coordinates, class_="unit")
)
for unit in elevation:
# Loop through all units in the elevation
@@ -441,9 +459,9 @@ class RackElevationHelperMixin:
# Setup drawing coordinates
start_y = unit_cursor * unit_height
end_y = unit_height * height
start_cordinates = (0, start_y)
end_cordinates = (unit_width, end_y)
text_cordinates = (unit_width / 2, start_y + end_y / 2)
start_cordinates = (legend_width, start_y)
end_cordinates = (legend_width + unit_width, end_y)
text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
# Draw the device
if device and device.face == face:
@@ -453,18 +471,19 @@ class RackElevationHelperMixin:
else:
# Draw shallow devices, reservations, or empty units
class_ = 'slot'
reservation = reserved_units.get(unit["id"])
if device:
class_ += ' occupied'
if unit["id"] in reserved_units:
if reservation:
class_ += ' reserved'
self._draw_empty(
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
)
unit_cursor += height
# Wrap the drawing with a border
drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack'))
drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
return drawing
@@ -483,7 +502,13 @@ class RackElevationHelperMixin:
return elevation
def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20):
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
):
"""
Return an SVG of the rack elevation
@@ -493,9 +518,9 @@ class RackElevationHelperMixin:
height of the elevation
"""
elevation = self.merge_elevations(face)
reserved_units = self.get_reserved_units().keys()
reserved_units = self.get_reserved_units()
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height)
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
@@ -506,6 +531,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
facility_id = models.CharField(
max_length=50,
blank=True,
@@ -569,7 +599,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
help_text='Rail-to-rail width'
)
u_height = models.PositiveSmallIntegerField(
default=42,
default=RACK_U_HEIGHT_DEFAULT,
verbose_name='Height (U)',
validators=[MinValueValidator(1), MaxValueValidator(100)]
)
@@ -602,8 +632,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -624,12 +652,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
}
class Meta:
ordering = ('site', 'group', 'name', 'pk') # (site, group, name) may be non-unique
unique_together = [
ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique
unique_together = (
# Name and facility_id must be unique *only* within a RackGroup
['group', 'name'],
['group', 'facility_id'],
]
('group', 'name'),
('group', 'facility_id'),
)
def __str__(self):
return self.display_name or super().__str__()
@@ -1008,9 +1036,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
]
clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
]
@@ -1306,6 +1331,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
blank=True,
null=True
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True,
null=True
)
serial = models.CharField(
max_length=50,
blank=True,
@@ -1400,8 +1431,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -1423,12 +1452,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
}
class Meta:
ordering = ('name', 'pk') # Name may be NULL
unique_together = [
['site', 'tenant', 'name'], # See validate_unique below
['rack', 'position', 'face'],
['virtual_chassis', 'vc_position'],
]
ordering = ('_name', 'pk') # Name may be null
unique_together = (
('site', 'tenant', 'name'), # See validate_unique below
('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'),
)
permissions = (
('napalm_read', 'Read-only access to devices via NAPALM'),
('napalm_write', 'Read/write access to devices via NAPALM'),
@@ -1445,10 +1474,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# Check for a duplicate name on a device assigned to the same Site and no Tenant. This is necessary
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.tenant is None and Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
raise ValidationError({
'name': 'A device with this name already exists.'
})
if self.name and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(name=self.name, tenant__isnull=True):
raise ValidationError({
'name': 'A device with this name already exists.'
})
super().validate_unique(exclude)
@@ -1858,15 +1888,15 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
)
voltage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=120
default=POWERFEED_VOLTAGE_DEFAULT
)
amperage = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1)],
default=20
default=POWERFEED_AMPERAGE_DEFAULT
)
max_utilization = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(100)],
default=80,
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
help_text="Maximum permissible draw (percentage)"
)
available_power = models.PositiveIntegerField(

View File

@@ -4,9 +4,9 @@ from django.db import models
from dcim.choices import *
from dcim.constants import *
from dcim.managers import InterfaceManager
from extras.models import ObjectChange
from utilities.managers import NaturalOrderingManager
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
@@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel):
help_text="Allocated power draw (watts)"
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -159,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel):
return PowerPort(
device=device,
name=self.name,
type=self.type,
maximum_draw=self.maximum_draw,
allocated_draw=self.allocated_draw
)
@@ -176,6 +186,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -195,11 +210,9 @@ class PowerOutletTemplate(ComponentTemplateModel):
help_text="Phase (for three-phase feeds)"
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -220,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
return PowerOutlet(
device=device,
name=self.name,
type=self.type,
power_port=power_port,
feed_leg=self.feed_leg
)
@@ -237,6 +251,12 @@ class InterfaceTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=InterfaceTypeChoices
@@ -246,11 +266,9 @@ class InterfaceTemplate(ComponentTemplateModel):
verbose_name='Management only'
)
objects = InterfaceManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -276,6 +294,11 @@ class FrontPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -290,14 +313,12 @@ class FrontPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = [
['device_type', 'name'],
['rear_port', 'rear_port_position'],
]
ordering = ('device_type', '_name')
unique_together = (
('device_type', 'name'),
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
@@ -344,6 +365,11 @@ class RearPortTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -353,11 +379,9 @@ class RearPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
@@ -383,12 +407,15 @@ class DeviceBayTemplate(ComponentTemplateModel):
name = models.CharField(
max_length=50
)
objects = NaturalOrderingManager()
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
class Meta:
ordering = ['device_type', 'name']
unique_together = ['device_type', 'name']
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name

View File

@@ -10,9 +10,9 @@ from dcim.choices import *
from dcim.constants import *
from dcim.exceptions import LoopDetected
from dcim.fields import MACAddressField
from dcim.managers import InterfaceManager
from extras.models import ObjectChange, TaggedItem
from utilities.managers import NaturalOrderingManager
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
@@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
class Meta:
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel):
choices=CONNECTION_STATUS_CHOICES,
blank=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
class Meta:
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -515,6 +529,12 @@ class Interface(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
max_length=100,
blank=True
)
_connected_interface = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
@@ -583,8 +603,6 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
objects = InterfaceManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
@@ -593,8 +611,9 @@ class Interface(CableTermination, ComponentModel):
]
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -676,7 +695,7 @@ class Interface(CableTermination, ComponentModel):
self.untagged_vlan = None
# Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
if self.pk and self.mode is not InterfaceModeChoices.MODE_TAGGED:
if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
self.tagged_vlans.clear()
return super().save(*args, **kwargs)
@@ -761,6 +780,11 @@ class FrontPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -774,20 +798,17 @@ class FrontPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
is_path_endpoint = False
class Meta:
ordering = ['device', 'name']
unique_together = [
['device', 'name'],
['rear_port', 'rear_port_position'],
]
ordering = ('device', '_name')
unique_together = (
('device', 'name'),
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
@@ -831,6 +852,11 @@ class RearPort(CableTermination, ComponentModel):
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -839,17 +865,14 @@ class RearPort(CableTermination, ComponentModel):
default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
is_path_endpoint = False
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
@@ -881,6 +904,11 @@ class DeviceBay(ComponentModel):
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
@@ -888,15 +916,13 @@ class DeviceBay(ComponentModel):
blank=True,
null=True
)
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'installed_device', 'description']
class Meta:
ordering = ['device', 'name']
unique_together = ['device', 'name']
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return '{} - {}'.format(self.device.name, self.name)
@@ -960,6 +986,11 @@ class InventoryItem(ComponentModel):
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
@@ -997,14 +1028,14 @@ class InventoryItem(ComponentModel):
]
class Meta:
ordering = ['device__id', 'parent__id', 'name']
unique_together = ['device', 'parent', 'name']
ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
def to_csv(self):
return (

View File

@@ -200,6 +200,11 @@ def get_component_template_actions(model_name):
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{{% endif %}}
{{% if perms.dcim.delete_{model_name} %}}
<a href="{{% url 'dcim:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-xs btn-danger">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{{% endif %}}
""".format(model_name=model_name).strip()
@@ -229,7 +234,7 @@ class RegionTable(BaseTable):
class SiteTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
name = tables.LinkColumn(order_by=('_name',))
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -291,7 +296,7 @@ class RackRoleTable(BaseTable):
class RackTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3'))
name = tables.LinkColumn(order_by=('_name',))
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
@@ -409,6 +414,7 @@ class DeviceTypeTable(BaseTable):
class ConsolePortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -432,6 +438,7 @@ class ConsolePortImportTable(BaseTable):
class ConsoleServerPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleserverporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -440,7 +447,7 @@ class ConsoleServerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'actions')
fields = ('pk', 'name', 'type', 'actions')
empty_text = "None"
@@ -455,6 +462,7 @@ class ConsoleServerPortImportTable(BaseTable):
class PowerPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('powerporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -478,6 +486,7 @@ class PowerPortImportTable(BaseTable):
class PowerOutletTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('poweroutlettemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -526,6 +535,7 @@ class InterfaceImportTable(BaseTable):
class FrontPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
rear_port_position = tables.Column(
verbose_name='Position'
)
@@ -552,6 +562,7 @@ class FrontPortImportTable(BaseTable):
class RearPortTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('rearporttemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -575,6 +586,7 @@ class RearPortImportTable(BaseTable):
class DeviceBayTemplateTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
actions = tables.TemplateColumn(
template_code=get_component_template_actions('devicebaytemplate'),
attrs={'td': {'class': 'text-right noprint'}},
@@ -654,7 +666,7 @@ class PlatformTable(BaseTable):
class DeviceTable(BaseTable):
pk = ToggleColumn()
name = tables.TemplateColumn(
order_by=('_nat1', '_nat2', '_nat3'),
order_by=('_name',),
template_code=DEVICE_LINK
)
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
@@ -704,6 +716,7 @@ class DeviceImportTable(BaseTable):
class DeviceComponentDetailTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn()
class Meta(BaseTable.Meta):
@@ -713,6 +726,7 @@ class DeviceComponentDetailTable(BaseTable):
class ConsolePortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = ConsolePort
@@ -727,6 +741,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable):
class ConsoleServerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = ConsoleServerPort
@@ -741,6 +756,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
class PowerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = PowerPort
@@ -755,6 +771,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable):
class PowerOutletTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = PowerOutlet
@@ -777,6 +794,7 @@ class InterfaceTable(BaseTable):
class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name')
@@ -785,6 +803,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable):
class FrontPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = FrontPort
@@ -800,6 +819,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable):
class RearPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = RearPort
@@ -815,6 +835,7 @@ class RearPortDetailTable(DeviceComponentDetailTable):
class DeviceBayTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
model = DeviceBay

View File

@@ -4,6 +4,7 @@ from netaddr import IPNetwork
from rest_framework import status
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.api import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import (
@@ -595,6 +596,21 @@ class RackTest(APITestCase):
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation(self):
url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation_svg(self):
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
def test_list_racks(self):
url = reverse('dcim-api:rack-list')
@@ -1900,6 +1916,31 @@ class DeviceTest(APITestCase):
self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
def test_get_device_graphs(self):
device_ct = ContentType.objects.get_for_model(Device)
self.graph1 = Graph.objects.create(
type=device_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=device_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=device_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'
)
url = reverse('dcim-api:device-graphs', kwargs={'pk': self.device1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Test Device 1&foo=1')
def test_list_devices(self):
url = reverse('dcim-api:device-list')
@@ -2134,6 +2175,31 @@ class ConsolePortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsolePort.objects.count(), 2)
def test_trace_consoleport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_server_port = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
cable = Cable(termination_a=self.consoleport1, termination_b=console_server_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': self.consoleport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_server_port.name)
class ConsoleServerPortTest(APITestCase):
@@ -2245,6 +2311,31 @@ class ConsoleServerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConsoleServerPort.objects.count(), 2)
def test_trace_consoleserverport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
console_port = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
cable = Cable(termination_a=self.consoleserverport1, termination_b=console_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': self.consoleserverport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.consoleserverport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], console_port.name)
class PowerPortTest(APITestCase):
@@ -2358,6 +2449,31 @@ class PowerPortTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerPort.objects.count(), 2)
def test_trace_powerport(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_outlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
cable = Cable(termination_a=self.powerport1, termination_b=power_outlet, label='Cable 1')
cable.save()
url = reverse('dcim-api:powerport-trace', kwargs={'pk': self.powerport1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.powerport1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_outlet.name)
class PowerOutletTest(APITestCase):
@@ -2469,6 +2585,31 @@ class PowerOutletTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerOutlet.objects.count(), 2)
def test_trace_poweroutlet(self):
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
power_port = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
cable = Cable(termination_a=self.poweroutlet1, termination_b=power_port, label='Cable 1')
cable.save()
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': self.poweroutlet1.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], self.poweroutlet1.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], power_port.name)
class InterfaceTest(APITestCase):
@@ -2673,6 +2814,262 @@ class InterfaceTest(APITestCase):
self.assertEqual(Interface.objects.count(), 2)
class FrontPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
rear_ports = RearPort.objects.bulk_create((
RearPort(device=self.device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
RearPort(device=self.device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
))
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0])
self.frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1])
self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2])
def test_get_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.frontport1.name)
def test_list_frontports(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_frontports_brief(self):
url = reverse('dcim-api:frontport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 4)
frontport4 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport4.device_id, data['device'])
self.assertEqual(frontport4.name, data['name'])
def test_create_frontport_bulk(self):
rear_ports = RearPort.objects.filter(frontports__isnull=True)
data = [
{
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[0].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 5',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[1].pk,
'rear_port_position': 1,
},
{
'device': self.device.pk,
'name': 'Front Port 6',
'type': PortTypeChoices.TYPE_8P8C,
'rear_port': rear_ports[2].pk,
'rear_port_position': 1,
},
]
url = reverse('dcim-api:frontport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(FrontPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_frontport(self):
rear_port = RearPort.objects.get(name='Rear Port 4')
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH,
'rear_port': rear_port.pk,
'rear_port_position': 1,
}
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(FrontPort.objects.count(), 3)
frontport1 = FrontPort.objects.get(pk=response.data['id'])
self.assertEqual(frontport1.name, data['name'])
self.assertEqual(frontport1.type, data['type'])
self.assertEqual(frontport1.rear_port, rear_port)
def test_delete_frontport(self):
url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(FrontPort.objects.count(), 2)
class RearPortTest(APITestCase):
def setUp(self):
super().setUp()
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
self.device = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
)
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 1')
self.rearport3 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 2')
self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 3')
def test_get_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.rearport1.name)
def test_list_rearports(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_rearports_brief(self):
url = reverse('dcim-api:rearport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cable', 'device', 'id', 'name', 'url']
)
def test_create_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
}
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 4)
rearport4 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport4.device_id, data['device'])
self.assertEqual(rearport4.name, data['name'])
def test_create_rearport_bulk(self):
data = [
{
'device': self.device.pk,
'name': 'Rear Port 4',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 5',
'type': PortTypeChoices.TYPE_8P8C,
},
{
'device': self.device.pk,
'name': 'Rear Port 6',
'type': PortTypeChoices.TYPE_8P8C,
},
]
url = reverse('dcim-api:rearport-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(RearPort.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_rearport(self):
data = {
'device': self.device.pk,
'name': 'Front Port X',
'type': PortTypeChoices.TYPE_110_PUNCH
}
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(RearPort.objects.count(), 3)
rearport1 = RearPort.objects.get(pk=response.data['id'])
self.assertEqual(rearport1.name, data['name'])
self.assertEqual(rearport1.type, data['type'])
def test_delete_rearport(self):
url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(RearPort.objects.count(), 2)
class DeviceBayTest(APITestCase):
def setUp(self):

View File

@@ -2,6 +2,7 @@ from django.test import TestCase
from dcim.forms import *
from dcim.models import *
from virtualization.models import Cluster, ClusterGroup, ClusterType
def get_id(model, slug):
@@ -10,71 +11,108 @@ def get_id(model, slug):
class DeviceTestCase(TestCase):
fixtures = ['dcim', 'ipam']
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
rack = Rack.objects.create(name='Rack 1', site=site)
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1
)
device_role = DeviceRole.objects.create(
name='Device Role 1', slug='device-role-1', color='ff0000'
)
Platform.objects.create(name='Platform 1', slug='platform-1')
Device.objects.create(
name='Device 1', device_type=device_type, device_role=device_role, site=site, rack=rack, position=1
)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
Cluster.objects.create(name='Cluster 1', type=cluster_type, group=cluster_group)
def test_racked_device(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'leaf-switch'),
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'juniper'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': Rack.objects.first().pk,
'face': DeviceFaceChoices.FACE_FRONT,
'position': 41,
'platform': get_id(Platform, 'juniper-junos'),
'position': 2,
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid(), test.fields['position'].choices)
self.assertTrue(test.save())
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_racked_device_occupied(self):
test = DeviceForm(data={
form = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'leaf-switch'),
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'juniper'),
'device_type': get_id(DeviceType, 'qfx5100-48s'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': Rack.objects.first().pk,
'face': DeviceFaceChoices.FACE_FRONT,
'position': 1,
'platform': get_id(Platform, 'juniper-junos'),
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertFalse(test.is_valid())
self.assertFalse(form.is_valid())
self.assertIn('position', form.errors)
def test_non_racked_device(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'pdu'),
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'servertech'),
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'site': get_id(Site, 'test1'),
'rack': '1',
'face': '',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'face': None,
'position': None,
'platform': None,
'platform': Platform.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
self.assertTrue(form.is_valid())
self.assertTrue(form.save())
def test_non_racked_device_with_face(self):
test = DeviceForm(data={
'name': 'test',
'device_role': get_id(DeviceRole, 'pdu'),
def test_non_racked_device_with_face_position(self):
form = DeviceForm(data={
'name': 'New Device',
'device_role': DeviceRole.objects.first().pk,
'tenant': None,
'manufacturer': get_id(Manufacturer, 'servertech'),
'device_type': get_id(DeviceType, 'cwg-24vym415c9'),
'site': get_id(Site, 'test1'),
'rack': '1',
'manufacturer': Manufacturer.objects.first().pk,
'device_type': DeviceType.objects.first().pk,
'site': Site.objects.first().pk,
'rack': None,
'face': DeviceFaceChoices.FACE_REAR,
'position': None,
'position': 10,
'platform': None,
'status': DeviceStatusChoices.STATUS_ACTIVE,
})
self.assertTrue(test.is_valid())
self.assertTrue(test.save())
self.assertFalse(form.is_valid())
self.assertIn('face', form.errors)
self.assertIn('position', form.errors)
def test_initial_data_population(self):
device_type = DeviceType.objects.first()
cluster = Cluster.objects.first()
test = DeviceForm(initial={
'device_type': device_type.pk,
'device_role': DeviceRole.objects.first().pk,
'status': DeviceStatusChoices.STATUS_ACTIVE,
'site': Site.objects.first().pk,
'cluster': cluster.pk,
})
# Check that the initial value for the manufacturer is set automatically when assigning the device type
self.assertEqual(test.initial['manufacturer'], device_type.manufacturer.pk)
# Check that the initial value for the cluster group is set automatically when assigning the cluster
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)

View File

@@ -285,7 +285,28 @@ class DeviceTestCase(TestCase):
name='Device Bay 1'
)
def test_device_duplicate_name_per_site(self):
def test_multiple_unnamed_devices(self):
device1 = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name=''
)
device1.save()
device2 = Device(
site=device1.site,
device_type=device1.device_type,
device_role=device1.device_role,
name=''
)
device2.full_clean()
device2.save()
self.assertEqual(Device.objects.filter(name='').count(), 2)
def test_device_duplicate_names(self):
device1 = Device(
site=self.site,

File diff suppressed because it is too large Load Diff

View File

@@ -14,317 +14,338 @@ app_name = 'dcim'
urlpatterns = [
# Regions
path(r'regions/', views.RegionListView.as_view(), name='region_list'),
path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path(r'regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path(r'regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
path('regions/', views.RegionListView.as_view(), name='region_list'),
path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
path(r'sites/', views.SiteListView.as_view(), name='site_list'),
path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path(r'sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
path(r'sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path(r'sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path(r'sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path(r'sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
path('sites/', views.SiteListView.as_view(), name='site_list'),
path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path('sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
path('sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path('sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path('sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path(r'rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path(r'rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path(r'rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path(r'rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path(r'rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path(r'rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path(r'rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks
path(r'racks/', views.RackListView.as_view(), name='rack_list'),
path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'),
path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path(r'racks/<int:pk>/', views.RackView.as_view(), name='rack'),
path(r'racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path(r'racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path(r'racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path(r'racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
path(r'racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', views.RackView.as_view(), name='rack'),
path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path('racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path(r'manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path(r'manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path(r'device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path(r'device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path(r'device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path(r'device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path('device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates
path(r'device-types/<int:pk>/console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
path(r'device-types/<int:pk>/console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
path(r'console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
# Console server port templates
path(r'device-types/<int:pk>/console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
path(r'device-types/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
path(r'console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
# Power port templates
path(r'device-types/<int:pk>/power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
path(r'device-types/<int:pk>/power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
path(r'power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
# Power outlet templates
path(r'device-types/<int:pk>/power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
path(r'device-types/<int:pk>/power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
path(r'power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
# Interface templates
path(r'device-types/<int:pk>/interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
path(r'device-types/<int:pk>/interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
path(r'device-types/<int:pk>/interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
path(r'interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
# Front port templates
path(r'device-types/<int:pk>/front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
path(r'device-types/<int:pk>/front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
path(r'front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
# Rear port templates
path(r'device-types/<int:pk>/rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
path(r'device-types/<int:pk>/rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
path(r'rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates
path(r'device-types/<int:pk>/device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
path(r'device-types/<int:pk>/device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
path(r'device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
# path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Device roles
path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path(r'device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path(r'device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'),
path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path(r'platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path(r'platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
path(r'devices/', views.DeviceListView.as_view(), name='device_list'),
path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path(r'devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path(r'devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path(r'devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path(r'devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path(r'devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path(r'devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path(r'devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path(r'devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path(r'devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path(r'devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
path(r'devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path(r'devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path('devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path(r'console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path(r'console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
# TODO: Bulk rename, disconnect views for ConsolePorts
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path(r'console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path(r'console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
path('console-server-ports/', views.ConsoleServerPortListView.as_view(), name='consoleserverport_list'),
path('console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path('console-server-ports/import/', views.ConsoleServerPortBulkImportView.as_view(), name='consoleserverport_import'),
path('console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path(r'power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path(r'power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'),
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
# TODO: Bulk rename, disconnect views for PowerPorts
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path(r'power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path(r'power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
path('power-outlets/', views.PowerOutletListView.as_view(), name='poweroutlet_list'),
path('power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path('power-outlets/import/', views.PowerOutletBulkImportView.as_view(), name='poweroutlet_import'),
path('power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path(r'interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path(r'interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
path('interfaces/', views.InterfaceListView.as_view(), name='interface_list'),
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path('interfaces/import/', views.InterfaceBulkImportView.as_view(), name='interface_import'),
path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
# path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path(r'front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path(r'front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
path('front-ports/', views.FrontPortListView.as_view(), name='frontport_list'),
path('front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path('front-ports/import/', views.FrontPortBulkImportView.as_view(), name='frontport_import'),
path('front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
# path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path(r'rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path(r'rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
path('rear-ports/', views.RearPortListView.as_view(), name='rearport_list'),
path('rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path('rear-ports/import/', views.RearPortBulkImportView.as_view(), name='rearport_import'),
path('rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
# path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Device bays
path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path(r'device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path(r'device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path('device-bays/import/', views.DeviceBayBulkImportView.as_view(), name='devicebay_import'),
# TODO: Bulk edit view for DeviceBays
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
# Inventory items
path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
path('inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
# TODO: Bulk rename view for InventoryItems
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
# Cables
path(r'cables/', views.CableListView.as_view(), name='cable_list'),
path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path(r'cables/<int:pk>/', views.CableView.as_view(), name='cable'),
path(r'cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path(r'cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path(r'cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path('cables/', views.CableListView.as_view(), name='cable_list'),
path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path('cables/<int:pk>/', views.CableView.as_view(), name='cable'),
path('cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path('cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path('cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
# Console/power/interface connections (read-only)
path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
path('power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
path('interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
# Virtual chassis
path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path(r'virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path(r'virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path(r'virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path('virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path('virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path('virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path('virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path('virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels
path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path('power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path('power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path('power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path('power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
# Power feeds
path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path('power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path('power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path('power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path('power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
]

View File

@@ -30,6 +30,7 @@ from utilities.views import (
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import DeviceFaceChoices
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -376,16 +377,15 @@ class RackElevationListView(PermissionRequiredMixin, View):
page = paginator.page(paginator.num_pages)
# Determine rack face
if request.GET.get('face') == '1':
face_id = 1
else:
face_id = 0
rack_face = request.GET.get('face', DeviceFaceChoices.FACE_FRONT)
if rack_face not in DeviceFaceChoices.values():
rack_face = DeviceFaceChoices.FACE_FRONT
return render(request, 'dcim/rack_elevation_list.html', {
'paginator': paginator,
'page': page,
'total_count': total_count,
'face_id': face_id,
'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET),
})
@@ -700,13 +700,11 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
#
# Device type components
# Console port templates
#
class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsolePortTemplate
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
@@ -719,17 +717,30 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.ConsolePortTemplateForm
class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
model = ConsolePortTemplate
class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
form = forms.ConsolePortTemplateBulkEditForm
class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleporttemplate'
queryset = ConsolePortTemplate.objects.all()
parent_model = DeviceType
table = tables.ConsolePortTemplateTable
#
# Console server port templates
#
class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = ConsoleServerPortTemplate
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
@@ -742,17 +753,30 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView)
model_form = forms.ConsoleServerPortTemplateForm
class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
model = ConsoleServerPortTemplate
class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverporttemplate'
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
form = forms.ConsoleServerPortTemplateBulkEditForm
class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverporttemplate'
queryset = ConsoleServerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.ConsoleServerPortTemplateTable
#
# Power port templates
#
class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerPortTemplate
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
@@ -765,17 +789,30 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerPortTemplateForm
class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
model = PowerPortTemplate
class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerporttemplate'
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
form = forms.PowerPortTemplateBulkEditForm
class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerporttemplate'
queryset = PowerPortTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerPortTemplateTable
#
# Power outlet templates
#
class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlettemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = PowerOutletTemplate
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
@@ -788,17 +825,30 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.PowerOutletTemplateForm
class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
model = PowerOutletTemplate
class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlettemplate'
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
form = forms.PowerOutletTemplateBulkEditForm
class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlettemplate'
queryset = PowerOutletTemplate.objects.all()
parent_model = DeviceType
table = tables.PowerOutletTemplateTable
#
# Interface templates
#
class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interfacetemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = InterfaceTemplate
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
@@ -811,10 +861,14 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.InterfaceTemplateForm
class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
model = InterfaceTemplate
class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interfacetemplate'
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
form = forms.InterfaceTemplateBulkEditForm
@@ -822,14 +876,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interfacetemplate'
queryset = InterfaceTemplate.objects.all()
parent_model = DeviceType
table = tables.InterfaceTemplateTable
#
# Front port templates
#
class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_frontporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = FrontPortTemplate
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
@@ -842,17 +897,30 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.FrontPortTemplateForm
class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_frontporttemplate'
model = FrontPortTemplate
class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_frontporttemplate'
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
form = forms.FrontPortTemplateBulkEditForm
class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_frontporttemplate'
queryset = FrontPortTemplate.objects.all()
parent_model = DeviceType
table = tables.FrontPortTemplateTable
#
# Rear port templates
#
class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_rearporttemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = RearPortTemplate
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
@@ -865,17 +933,30 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.RearPortTemplateForm
class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rearporttemplate'
model = RearPortTemplate
class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rearporttemplate'
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
form = forms.RearPortTemplateBulkEditForm
class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rearporttemplate'
queryset = RearPortTemplate.objects.all()
parent_model = DeviceType
table = tables.RearPortTemplateTable
#
# Device bay templates
#
class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebaytemplate'
parent_model = DeviceType
parent_field = 'device_type'
model = DeviceBayTemplate
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
@@ -888,10 +969,21 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
model_form = forms.DeviceBayTemplateForm
class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
model = DeviceBayTemplate
# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
# permission_required = 'dcim.change_devicebaytemplate'
# queryset = DeviceBayTemplate.objects.all()
# table = tables.DeviceBayTemplateTable
# form = forms.DeviceBayTemplateBulkEditForm
class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebaytemplate'
queryset = DeviceBayTemplate.objects.all()
parent_model = DeviceType
table = tables.DeviceBayTemplateTable
@@ -1200,13 +1292,11 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/consoleport_list.html'
class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleport'
parent_model = Device
parent_field = 'device'
model = ConsolePort
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
@@ -1231,11 +1321,18 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:consoleport_list'
class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleport'
queryset = ConsolePort.objects.all()
table = tables.ConsolePortTable
form = forms.ConsolePortBulkEditForm
class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleport'
queryset = ConsolePort.objects.all()
parent_model = Device
table = tables.ConsolePortTable
default_return_url = 'dcim:consoleport_list'
#
@@ -1248,13 +1345,11 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/consoleserverport_list.html'
class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_consoleserverport'
parent_model = Device
parent_field = 'device'
model = ConsoleServerPort
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
@@ -1282,7 +1377,6 @@ class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverport'
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
form = forms.ConsoleServerPortBulkEditForm
@@ -1302,8 +1396,8 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_consoleserverport'
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
default_return_url = 'dcim:consoleserverport_list'
#
@@ -1316,13 +1410,11 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/powerport_list.html'
class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_powerport'
parent_model = Device
parent_field = 'device'
model = PowerPort
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
@@ -1347,11 +1439,18 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:powerport_list'
class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerport'
queryset = PowerPort.objects.all()
table = tables.PowerPortTable
form = forms.PowerPortBulkEditForm
class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerport'
queryset = PowerPort.objects.all()
parent_model = Device
table = tables.PowerPortTable
default_return_url = 'dcim:powerport_list'
#
@@ -1364,13 +1463,11 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/poweroutlet_list.html'
class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_poweroutlet'
parent_model = Device
parent_field = 'device'
model = PowerOutlet
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
@@ -1398,7 +1495,6 @@ class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlet'
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
form = forms.PowerOutletBulkEditForm
@@ -1418,8 +1514,8 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_poweroutlet'
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
default_return_url = 'dcim:poweroutlet_list'
#
@@ -1432,7 +1528,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/interface_list.html'
class InterfaceView(PermissionRequiredMixin, View):
@@ -1473,8 +1569,6 @@ class InterfaceView(PermissionRequiredMixin, View):
class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_interface'
parent_model = Device
parent_field = 'device'
model = Interface
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
@@ -1503,7 +1597,6 @@ class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_interface'
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
@@ -1523,8 +1616,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_interface'
queryset = Interface.objects.all()
parent_model = Device
table = tables.InterfaceTable
default_return_url = 'dcim:interface_list'
#
@@ -1537,13 +1630,11 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/frontport_list.html'
class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_frontport'
parent_model = Device
parent_field = 'device'
model = FrontPort
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
@@ -1571,7 +1662,6 @@ class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_frontport'
queryset = FrontPort.objects.all()
parent_model = Device
table = tables.FrontPortTable
form = forms.FrontPortBulkEditForm
@@ -1591,8 +1681,8 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_frontport'
queryset = FrontPort.objects.all()
parent_model = Device
table = tables.FrontPortTable
default_return_url = 'dcim:frontport_list'
#
@@ -1605,13 +1695,11 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
table = tables.RearPortDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/rearport_list.html'
class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_rearport'
parent_model = Device
parent_field = 'device'
model = RearPort
form = forms.RearPortCreateForm
model_form = forms.RearPortForm
@@ -1639,7 +1727,6 @@ class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rearport'
queryset = RearPort.objects.all()
parent_model = Device
table = tables.RearPortTable
form = forms.RearPortBulkEditForm
@@ -1659,8 +1746,8 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rearport'
queryset = RearPort.objects.all()
parent_model = Device
table = tables.RearPortTable
default_return_url = 'dcim:rearport_list'
#
@@ -1675,13 +1762,11 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
filterset = filters.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayDetailTable
template_name = 'dcim/device_component_list.html'
template_name = 'dcim/devicebay_list.html'
class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_devicebay'
parent_model = Device
parent_field = 'device'
model = DeviceBay
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
@@ -1784,8 +1869,8 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicebay'
queryset = DeviceBay.objects.all()
parent_model = Device
table = tables.DeviceBayTable
default_return_url = 'dcim:devicebay_list'
#
@@ -1945,6 +2030,12 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
# Set initial site and rack based on side A termination (if not already set)
if 'termination_b_site' not in initial_data:
initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None)
if 'termination_b_rack' not in initial_data:
initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None)
form = self.form_class(instance=self.obj, initial=initial_data)
return render(request, self.template_name, {
@@ -2150,13 +2241,13 @@ class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
model = InventoryItem
model_form = forms.InventoryItemForm
def alter_obj(self, obj, request, url_args, url_kwargs):
if 'device' in url_kwargs:
obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
return obj
def get_return_url(self, request, obj):
return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk})
class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
permission_required = 'dcim.add_inventoryitem'
model = InventoryItem
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
template_name = 'dcim/device_component_add.html'
class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):

View File

@@ -20,6 +20,8 @@ from utilities.api import (
ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
ValidatedModelSerializer,
)
from virtualization.api.nested_serializers import NestedClusterGroupSerializer, NestedClusterSerializer
from virtualization.models import Cluster, ClusterGroup
from .nested_serializers import *
@@ -161,6 +163,18 @@ class ConfigContextSerializer(ValidatedModelSerializer):
required=False,
many=True
)
cluster_groups = SerializedPKRelatedField(
queryset=ClusterGroup.objects.all(),
serializer=NestedClusterGroupSerializer,
required=False,
many=True
)
clusters = SerializedPKRelatedField(
queryset=Cluster.objects.all(),
serializer=NestedClusterSerializer,
required=False,
many=True
)
tenant_groups = SerializedPKRelatedField(
queryset=TenantGroup.objects.all(),
serializer=NestedTenantGroupSerializer,
@@ -184,7 +198,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
model = ConfigContext
fields = [
'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
'tenant_groups', 'tenants', 'tags', 'data',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
]

View File

@@ -15,34 +15,34 @@ router = routers.DefaultRouter()
router.APIRootView = ExtrasRootView
# Field choices
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
# Custom field choices
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
router.register('_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
# Graphs
router.register(r'graphs', views.GraphViewSet)
router.register('graphs', views.GraphViewSet)
# Export templates
router.register(r'export-templates', views.ExportTemplateViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
# Tags
router.register(r'tags', views.TagViewSet)
router.register('tags', views.TagViewSet)
# Image attachments
router.register(r'image-attachments', views.ImageAttachmentViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
# Config contexts
router.register(r'config-contexts', views.ConfigContextViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
# Reports
router.register(r'reports', views.ReportViewSet, basename='report')
router.register('reports', views.ReportViewSet, basename='report')
# Scripts
router.register(r'scripts', views.ScriptViewSet, basename='script')
router.register('scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register(r'object-changes', views.ObjectChangeViewSet)
router.register('object-changes', views.ObjectChangeViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

View File

@@ -4,6 +4,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
@@ -170,6 +171,22 @@ class ConfigContextFilterSet(django_filters.FilterSet):
to_field_name='slug',
label='Platform (slug)',
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster_groups',
queryset=ClusterGroup.objects.all(),
label='Cluster group',
)
cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster_groups__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label='Cluster group (slug)',
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
field_name='clusters',
queryset=Cluster.objects.all(),
label='Cluster',
)
tenant_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='tenant_groups',
queryset=TenantGroup.objects.all(),

View File

@@ -1,35 +0,0 @@
[
{
"model": "extras.graph",
"pk": 1,
"fields": {
"type": 300,
"weight": 1000,
"name": "Site Test Graph",
"source": "http://localhost/na.png",
"link": ""
}
},
{
"model": "extras.graph",
"pk": 2,
"fields": {
"type": 200,
"weight": 1000,
"name": "Provider Test Graph",
"source": "http://localhost/provider_graph.png",
"link": ""
}
},
{
"model": "extras.graph",
"pk": 3,
"fields": {
"type": 100,
"weight": 1000,
"name": "Interface Test Graph",
"source": "http://localhost/interface_graph.png",
"link": ""
}
}
]

View File

@@ -1,18 +1,17 @@
from collections import OrderedDict
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -21,102 +20,41 @@ from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachmen
# Custom fields
#
def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=False):
"""
Retrieve all CustomFields applicable to the given ContentType
"""
field_dict = OrderedDict()
custom_fields = CustomField.objects.filter(obj_type=content_type)
if filterable_only:
custom_fields = custom_fields.exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
for cf in custom_fields:
field_name = 'cf_{}'.format(str(cf.name))
initial = cf.default if not bulk_edit else None
# Integer
if cf.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=cf.required, initial=initial)
# Boolean
elif cf.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif cf.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
# Select
elif cf.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc) for cfc in cf.choices.all()]
if not cf.required or bulk_edit or filterable_only:
choices = [(None, '---------')] + choices
# Check for a default choice
default_choice = None
if initial:
try:
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
field = forms.TypedChoiceField(
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
)
# URL
elif cf.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=cf.required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=cf.required, initial=initial)
field.model = cf
field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize()
if cf.description:
field.help_text = cf.description
field_dict[field_name] = field
return field_dict
class CustomFieldForm(forms.ModelForm):
class CustomFieldModelForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self._meta.model)
self.custom_fields = []
self.custom_field_values = {}
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
custom_fields = []
for name, field in get_custom_fields_for_model(self.obj_type).items():
self.fields[name] = field
custom_fields.append(name)
self.custom_fields = custom_fields
self._append_customfield_fields()
# If editing an existing object, initialize values for all custom fields
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this model.
"""
# Retrieve initial CustomField values for the instance
if self.instance.pk:
existing_values = CustomFieldValue.objects.filter(
for cfv in CustomFieldValue.objects.filter(
obj_type=self.obj_type,
obj_id=self.instance.pk
).prefetch_related('field')
for cfv in existing_values:
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
).prefetch_related('field'):
self.custom_field_values[cfv.field.name] = cfv.serialized_value
# Append form fields; assign initial values if modifying and existing object
for cf in CustomField.objects.filter(obj_type=self.obj_type):
field_name = 'cf_{}'.format(cf.name)
if self.instance.pk:
self.fields[field_name] = cf.to_form_field(set_initial=False)
self.fields[field_name].initial = self.custom_field_values.get(cf.name)
else:
self.fields[field_name] = cf.to_form_field()
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
def _save_custom_fields(self):
@@ -151,6 +89,19 @@ class CustomFieldForm(forms.ModelForm):
return obj
class CustomFieldModelCSVForm(CustomFieldModelForm):
def _append_customfield_fields(self):
# Append form fields
for cf in CustomField.objects.filter(obj_type=self.obj_type):
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(for_csv_import=True)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(field_name)
class CustomFieldBulkEditForm(BulkEditForm):
def __init__(self, *args, **kwargs):
@@ -160,15 +111,14 @@ class CustomFieldBulkEditForm(BulkEditForm):
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = get_custom_fields_for_model(self.obj_type, bulk_edit=True).items()
for name, field in custom_fields:
custom_fields = CustomField.objects.filter(obj_type=self.obj_type)
for cf in custom_fields:
# Annotate non-required custom fields as nullable
if not field.required:
self.nullable_fields.append(name)
field.required = False
self.fields[name] = field
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(name)
self.custom_fields.append(cf.name)
class CustomFieldFilterForm(forms.Form):
@@ -180,10 +130,12 @@ class CustomFieldFilterForm(forms.Form):
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
custom_fields = get_custom_fields_for_model(self.obj_type, filterable_only=True).items()
for name, field in custom_fields:
field.required = False
self.fields[name] = field
custom_fields = CustomField.objects.filter(obj_type=self.obj_type).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
for cf in custom_fields:
field_name = 'cf_{}'.format(cf.name)
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
#
@@ -239,7 +191,61 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
tags = forms.ModelMultipleChoiceField(
regions = TreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
widget=StaticSelect2Multiple()
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/"
)
)
roles = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/"
)
)
platforms = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/platforms/"
)
)
cluster_groups = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/"
)
)
clusters = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/clusters/"
)
)
tenant_groups = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/"
)
)
tenants = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/"
)
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
@@ -253,30 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConfigContext
fields = [
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
'tenants', 'tags', 'data',
]
widgets = {
'regions': APISelectMultiple(
api_url="/api/dcim/regions/"
),
'sites': APISelectMultiple(
api_url="/api/dcim/sites/"
),
'roles': APISelectMultiple(
api_url="/api/dcim/device-roles/"
),
'platforms': APISelectMultiple(
api_url="/api/dcim/platforms/"
),
'tenant_groups': APISelectMultiple(
api_url="/api/tenancy/tenant-groups/"
),
'tenants': APISelectMultiple(
api_url="/api/tenancy/tenants/"
),
}
fields = (
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups',
'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
)
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -308,57 +294,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
role = FilterChoiceField(
role = DynamicModelMultipleChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/",
value_field="slug",
)
)
platform = FilterChoiceField(
platform = DynamicModelMultipleChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/platforms/",
value_field="slug",
)
)
tenant_group = FilterChoiceField(
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/virtualization/cluster-groups/",
value_field="slug",
)
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label='Cluster',
widget=APISelectMultiple(
api_url="/api/virtualization/clusters/",
)
)
tenant_group = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/",
value_field="slug",
)
)
tenant = FilterChoiceField(
tenant = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field="slug",
)
)
tag = FilterChoiceField(
tag = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/extras/tags/",
value_field="slug",
@@ -415,11 +425,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
)
action = forms.ChoiceField(
choices=add_blank_choice(ObjectChangeActionChoices),
required=False
required=False,
widget=StaticSelect2()
)
# TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'),
required=False
required=False,
widget=StaticSelect2()
)
changed_object_type = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'),

View File

@@ -0,0 +1,111 @@
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from utilities.fields import NaturalOrderingField
class Command(BaseCommand):
help = "Recalculate natural ordering values for the specified models"
def add_arguments(self, parser):
parser.add_argument(
'args', metavar='app_label.ModelName', nargs='*',
help='One or more specific models (each prefixed with its app_label) to renaturalize',
)
def _get_models(self, names):
"""
Compile a list of models to be renaturalized. If no names are specified, all models which have one or more
NaturalOrderingFields will be included.
"""
models = []
if names:
# Collect all NaturalOrderingFields present on the specified models
for name in names:
try:
app_label, model_name = name.split('.')
except ValueError:
raise CommandError(
"Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name)
)
try:
app_config = apps.get_app_config(app_label)
except LookupError as e:
raise CommandError(str(e))
try:
model = app_config.get_model(model_name)
except LookupError:
raise CommandError("Unknown model: {}.{}".format(app_label, model_name))
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if not fields:
raise CommandError(
"Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name)
)
models.append(
(model, fields)
)
else:
# Find *all* models with NaturalOrderingFields
for app_config in apps.get_app_configs():
for model in app_config.models.values():
fields = [
field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField
]
if fields:
models.append(
(model, fields)
)
return models
def handle(self, *args, **options):
models = self._get_models(args)
if options['verbosity']:
self.stdout.write("Renaturalizing {} models.".format(len(models)))
for model, fields in models:
for field in fields:
target_field = field.target_field
naturalize = field.naturalize_function
count = 0
# Print the model and field name
if options['verbosity']:
self.stdout.write(
"{}.{} ({})... ".format(model._meta.label, field.target_field, field.name),
ending='\n' if options['verbosity'] >= 2 else ''
)
self.stdout.flush()
# Find all unique values for the field
queryset = model.objects.values_list(target_field, flat=True).order_by(target_field).distinct()
for value in queryset:
naturalized_value = naturalize(value, max_length=field.max_length)
if options['verbosity'] >= 2:
self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='')
self.stdout.flush()
# Update each unique field value in bulk
changed = model.objects.filter(name=value).update(**{field.name: naturalized_value})
if options['verbosity'] >= 2:
self.stdout.write(" ({})".format(changed))
count += changed
# Print the total count of alterations for the field
if options['verbosity'] >= 2:
self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format(
count, model._meta.verbose_name_plural, queryset.count()
)))
elif options['verbosity']:
self.stdout.write(self.style.SUCCESS(str(count)))
if options['verbosity']:
self.stdout.write(self.style.SUCCESS("Done."))

View File

@@ -0,0 +1,24 @@
# Generated by Django 2.2.8 on 2020-01-17 18:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0013_deterministic_ordering'),
('extras', '0036_contenttype_filters_to_q_objects'),
]
operations = [
migrations.AddField(
model_name='configcontext',
name='cluster_groups',
field=models.ManyToManyField(blank=True, related_name='_configcontext_cluster_groups_+', to='virtualization.ClusterGroup'),
),
migrations.AddField(
model_name='configcontext',
name='clusters',
field=models.ManyToManyField(blank=True, related_name='_configcontext_clusters_+', to='virtualization.Cluster'),
),
]

View File

@@ -1,6 +1,7 @@
from collections import OrderedDict
from datetime import date
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -14,6 +15,7 @@ from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField
from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.utils import deepmerge, render_jinja2
from .choices import *
from .constants import *
@@ -280,6 +282,75 @@ class CustomField(models.Model):
return self.choices.get(pk=int(serialized_value))
return serialized_value
def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
initial = self.default if set_initial else None
required = self.required if enforce_required else False
# Integer
if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
field = forms.IntegerField(required=required, initial=initial)
# Boolean
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
choices = (
(None, '---------'),
(1, 'True'),
(0, 'False'),
)
if initial is not None and initial.lower() in ['true', 'yes', '1']:
initial = 1
elif initial is not None and initial.lower() in ['false', 'no', '0']:
initial = 0
else:
initial = None
field = forms.NullBooleanField(
required=required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
field = forms.DateField(required=required, initial=initial, widget=DatePicker())
# Select
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
if not required:
choices = add_blank_choice(choices)
# Set the initial value to the PK of the default choice, if any
if set_initial:
default_choice = self.choices.filter(value=self.default).first()
if default_choice:
initial = default_choice.pk
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelect2()
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
field = LaxURLField(required=required, initial=initial)
# Text
else:
field = forms.CharField(max_length=255, required=required, initial=initial)
field.model = self
field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
if self.description:
field.help_text = self.description
return field
class CustomFieldValue(models.Model):
field = models.ForeignKey(
@@ -694,6 +765,16 @@ class ConfigContext(models.Model):
related_name='+',
blank=True
)
cluster_groups = models.ManyToManyField(
to='virtualization.ClusterGroup',
related_name='+',
blank=True
)
clusters = models.ManyToManyField(
to='virtualization.Cluster',
related_name='+',
blank=True
)
tenant_groups = models.ManyToManyField(
to='tenancy.TenantGroup',
related_name='+',

View File

@@ -29,6 +29,10 @@ class ConfigContextQuerySet(QuerySet):
# `device_role` for Device; `role` for VirtualMachine
role = getattr(obj, 'device_role', None) or obj.role
# Virtualization cluster for VirtualMachine
cluster = getattr(obj, 'cluster', None)
cluster_group = getattr(cluster, 'group', None)
# Get the group of the assigned tenant, if any
tenant_group = obj.tenant.group if obj.tenant else None
@@ -44,6 +48,8 @@ class ConfigContextQuerySet(QuerySet):
Q(sites=obj.site) | Q(sites=None),
Q(roles=role) | Q(roles=None),
Q(platforms=obj.platform) | Q(platforms=None),
Q(cluster_groups=cluster_group) | Q(cluster_groups=None),
Q(clusters=cluster) | Q(clusters=None),
Q(tenant_groups=tenant_group) | Q(tenant_groups=None),
Q(tenants=obj.tenant) | Q(tenants=None),
Q(tags__slug__in=obj.tags.slugs()) | Q(tags=None),

View File

@@ -14,10 +14,10 @@ from django.db import transaction
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
from mptt.models import MPTTModel
from ipam.formfields import IPFormField
from utilities.exceptions import AbortTransaction
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from utilities.exceptions import AbortTransaction
from .forms import ScriptForm
from .signals import purge_changelog
@@ -27,6 +27,8 @@ __all__ = [
'ChoiceVar',
'FileVar',
'IntegerVar',
'IPAddressVar',
'IPAddressWithMaskVar',
'IPNetworkVar',
'MultiObjectVar',
'ObjectVar',
@@ -46,17 +48,24 @@ class ScriptVariable:
"""
form_field = forms.CharField
def __init__(self, label='', description='', default=None, required=True):
def __init__(self, label='', description='', default=None, required=True, widget=None):
# Default field attributes
self.field_attrs = {
'help_text': description,
'required': required
}
# Initialize field attributes
if not hasattr(self, 'field_attrs'):
self.field_attrs = {}
if label:
self.field_attrs['label'] = label
if description:
self.field_attrs['help_text'] = description
if default:
self.field_attrs['initial'] = default
if widget:
self.field_attrs['widget'] = widget
self.field_attrs['required'] = required
# Initialize the list of optional validators if none have already been defined
if 'validators' not in self.field_attrs:
self.field_attrs['validators'] = []
def as_field(self):
"""
@@ -64,7 +73,10 @@ class ScriptVariable:
"""
form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput):
form_field.widget.attrs['class'] = 'form-control'
if form_field.widget.attrs and 'class' in form_field.widget.attrs.keys():
form_field.widget.attrs['class'] += ' form-control'
else:
form_field.widget.attrs['class'] = 'form-control'
return form_field
@@ -196,17 +208,32 @@ class FileVar(ScriptVariable):
form_field = forms.FileField
class IPAddressVar(ScriptVariable):
"""
An IPv4 or IPv6 address without a mask.
"""
form_field = IPAddressFormField
class IPAddressWithMaskVar(ScriptVariable):
"""
An IPv4 or IPv6 address with a mask.
"""
form_field = IPNetworkFormField
class IPNetworkVar(ScriptVariable):
"""
An IPv4 or IPv6 prefix.
"""
form_field = IPFormField
form_field = IPNetworkFormField
field_attrs = {
'validators': [prefix_validator]
}
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['validators'] = list()
# Optional minimum/maximum prefix lengths
if min_prefix_length is not None:
self.field_attrs['validators'].append(

View File

@@ -1,14 +1,15 @@
from datetime import date
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from django.test import Client, TestCase
from django.urls import reverse
from rest_framework import status
from dcim.forms import SiteCSVForm
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from utilities.testing import APITestCase
from utilities.testing import APITestCase, create_test_user
from virtualization.models import VirtualMachine
@@ -364,3 +365,113 @@ class CustomFieldChoiceAPITest(APITestCase):
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
class CustomFieldImportTest(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'dcim.view_site',
'dcim.add_site',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
custom_fields = (
CustomField(name='text', type=CustomFieldTypeChoices.TYPE_TEXT),
CustomField(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER),
CustomField(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN),
CustomField(name='date', type=CustomFieldTypeChoices.TYPE_DATE),
CustomField(name='url', type=CustomFieldTypeChoices.TYPE_URL),
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT),
)
for cf in custom_fields:
cf.save()
cf.obj_type.set([ContentType.objects.get_for_model(Site)])
CustomFieldChoice.objects.bulk_create((
CustomFieldChoice(field=custom_fields[5], value='Choice A'),
CustomFieldChoice(field=custom_fields[5], value='Choice B'),
CustomFieldChoice(field=custom_fields[5], value='Choice C'),
))
def test_import(self):
"""
Import a Site in CSV format, including a value for each CustomField.
"""
data = (
('name', 'slug', 'cf_text', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_select'),
('Site 1', 'site-1', 'ABC', '123', 'True', '2020-01-01', 'http://example.com/1', 'Choice A'),
('Site 2', 'site-2', 'DEF', '456', 'False', '2020-01-02', 'http://example.com/2', 'Choice B'),
('Site 3', 'site-3', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200)
# Validate data for site 1
custom_field_values = {
cf.name: value for cf, value in Site.objects.get(name='Site 1').get_custom_fields().items()
}
self.assertEqual(len(custom_field_values), 6)
self.assertEqual(custom_field_values['text'], 'ABC')
self.assertEqual(custom_field_values['integer'], 123)
self.assertEqual(custom_field_values['boolean'], True)
self.assertEqual(custom_field_values['date'], date(2020, 1, 1))
self.assertEqual(custom_field_values['url'], 'http://example.com/1')
self.assertEqual(custom_field_values['select'].value, 'Choice A')
# Validate data for site 2
custom_field_values = {
cf.name: value for cf, value in Site.objects.get(name='Site 2').get_custom_fields().items()
}
self.assertEqual(len(custom_field_values), 6)
self.assertEqual(custom_field_values['text'], 'DEF')
self.assertEqual(custom_field_values['integer'], 456)
self.assertEqual(custom_field_values['boolean'], False)
self.assertEqual(custom_field_values['date'], date(2020, 1, 2))
self.assertEqual(custom_field_values['url'], 'http://example.com/2')
self.assertEqual(custom_field_values['select'].value, 'Choice B')
# No CustomFieldValues should be created for site 3
obj_type = ContentType.objects.get_for_model(Site)
site3 = Site.objects.get(name='Site 3')
self.assertFalse(CustomFieldValue.objects.filter(obj_type=obj_type, obj_id=site3.pk).exists())
self.assertEqual(CustomFieldValue.objects.count(), 12) # Sanity check
def test_import_missing_required(self):
"""
Attempt to import an object missing a required custom field.
"""
# Set one of our CustomFields to required
CustomField.objects.filter(name='text').update(required=True)
form_data = {
'name': 'Site 1',
'slug': 'site-1',
}
form = SiteCSVForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn('cf_text', form.errors)
def test_import_invalid_choice(self):
"""
Attempt to import an object with an invalid choice selection.
"""
form_data = {
'name': 'Site 1',
'slug': 'site-1',
'cf_select': 'Choice X'
}
form = SiteCSVForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn('cf_select', form.errors)

View File

@@ -7,6 +7,7 @@ from extras.constants import GRAPH_MODELS
from extras.filters import *
from extras.models import ConfigContext, ExportTemplate, Graph
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterGroup, ClusterType
class GraphTestCase(TestCase):
@@ -107,6 +108,21 @@ class ConfigContextTestCase(TestCase):
)
Platform.objects.bulk_create(platforms)
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
Cluster(name='Cluster 1', type=cluster_type),
Cluster(name='Cluster 2', type=cluster_type),
Cluster(name='Cluster 3', type=cluster_type),
)
Cluster.objects.bulk_create(clusters)
tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
@@ -132,6 +148,8 @@ class ConfigContextTestCase(TestCase):
c.sites.set([sites[i]])
c.roles.set([device_roles[i]])
c.platforms.set([platforms[i]])
c.cluster_groups.set([cluster_groups[i]])
c.clusters.set([clusters[i]])
c.tenant_groups.set([tenant_groups[i]])
c.tenants.set([tenants[i]])
@@ -173,6 +191,18 @@ class ConfigContextTestCase(TestCase):
params = {'platform': [platforms[0].slug, platforms[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster_group(self):
cluster_groups = ClusterGroup.objects.all()[:2]
params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster(self):
clusters = Cluster.objects.all()[:2]
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}

View File

@@ -1,6 +1,6 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPNetwork
from netaddr import IPAddress, IPNetwork
from dcim.models import DeviceRole
from extras.scripts import *
@@ -186,6 +186,54 @@ class ScriptVariablesTest(TestCase):
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], testfile)
def test_ipaddressvar(self):
class TestScript(Script):
var1 = IPAddressVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask exclusion
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.1'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPAddress(data['var1']))
def test_ipaddresswithmaskvar(self):
class TestScript(Script):
var1 = IPAddressWithMaskVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate IP mask requirement
data = {'var1': '192.0.2.0'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
def test_ipnetworkvar(self):
class TestScript(Script):
@@ -198,6 +246,12 @@ class ScriptVariablesTest(TestCase):
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate host IP check
data = {'var1': '192.0.2.1/24'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)

View File

@@ -2,86 +2,102 @@ import urllib.parse
import uuid
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import create_test_user
from utilities.testing import ViewTestCases, TestCase
class TagTestCase(TestCase):
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tag
def setUp(self):
user = create_test_user(permissions=['extras.view_tag'])
self.client = Client()
self.client.force_login(user)
# Disable inapplicable tests
test_create_object = None
test_import_objects = None
Tag.objects.bulk_create([
@classmethod
def setUpTestData(cls):
Tag.objects.bulk_create((
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'),
])
))
def test_tag_list(self):
url = reverse('extras:tag_list')
params = {
"q": "tag",
cls.form_data = {
'name': 'Tag X',
'slug': 'tag-x',
'color': 'c0c0c0',
'comments': 'Some comments',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
cls.bulk_edit_data = {
'color': '00ff00',
}
class ConfigContextTestCase(TestCase):
class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ConfigContext
def setUp(self):
user = create_test_user(permissions=['extras.view_configcontext'])
self.client = Client()
self.client.force_login(user)
# Disable inapplicable tests
test_import_objects = None
site = Site(name='Site 1', slug='site-1')
site.save()
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
test_create_object = None
test_edit_object = None
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
# Create three ConfigContexts
for i in range(1, 4):
configcontext = ConfigContext(
name='Config Context {}'.format(i),
data='{{"foo": {}}}'.format(i)
data={'foo': i}
)
configcontext.save()
configcontext.sites.add(site)
def test_configcontext_list(self):
url = reverse('extras:configcontext_list')
params = {
"q": "foo",
cls.form_data = {
'name': 'Config Context X',
'weight': 200,
'description': 'A new config context',
'is_active': True,
'regions': [],
'sites': [site.pk],
'roles': [],
'platforms': [],
'tenant_groups': [],
'tenants': [],
'tags': [],
'data': '{"foo": 123}',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_configcontext(self):
configcontext = ConfigContext.objects.first()
response = self.client.get(configcontext.get_absolute_url())
self.assertEqual(response.status_code, 200)
cls.bulk_edit_data = {
'weight': 300,
'is_active': False,
'description': 'New description',
}
# TODO: Convert to StandardTestCases.Views
class ObjectChangeTestCase(TestCase):
user_permissions = (
'extras.view_objectchange',
)
def setUp(self):
user = create_test_user(permissions=['extras.view_objectchange'])
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ObjectChanges
user = User.objects.create_user(username='testuser2')
for i in range(1, 4):
oc = site.to_objectchange(action=ObjectChangeActionChoices.ACTION_UPDATE)
oc.user = user
@@ -96,10 +112,10 @@ class ObjectChangeTestCase(TestCase):
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
self.assertHttpStatus(response, 200)
def test_objectchange(self):
objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url())
self.assertEqual(response.status_code, 200)
self.assertHttpStatus(response, 200)

View File

@@ -1,11 +1,19 @@
import json
import uuid
from unittest.mock import patch
import django_rq
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.urls import reverse
from requests import Session
from rest_framework import status
from dcim.models import Site
from extras.choices import ObjectChangeActionChoices
from extras.models import Webhook
from extras.webhooks import enqueue_webhooks, generate_signature
from extras.webhooks_worker import process_webhook
from utilities.testing import APITestCase
@@ -22,11 +30,13 @@ class WebhookTest(APITestCase):
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
PAYLOAD_URL = "http://localhost/"
DUMMY_URL = "http://localhost/"
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=PAYLOAD_URL),
Webhook(name='Site Update Webhook', type_update=True, payload_url=PAYLOAD_URL),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=PAYLOAD_URL),
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
))
for webhook in webhooks:
webhook.obj_type.set([site_ct])
@@ -87,3 +97,47 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[1]['id'], site.pk)
self.assertEqual(job.args[2], 'site')
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE)
def test_webhooks_worker(self):
request_id = uuid.uuid4()
def dummy_send(_, request):
"""
A dummy implementation of Session.send() to be used for testing.
Always returns a 200 HTTP response.
"""
webhook = Webhook.objects.get(type_create=True)
signature = generate_signature(request.body, webhook.secret)
# Validate the outgoing request headers
self.assertEqual(request.headers['Content-Type'], webhook.http_content_type)
self.assertEqual(request.headers['X-Hook-Signature'], signature)
self.assertEqual(request.headers['X-Foo'], 'Bar')
# Validate the outgoing request body
body = json.loads(request.body)
self.assertEqual(body['event'], 'created')
self.assertEqual(body['timestamp'], job.args[4])
self.assertEqual(body['model'], 'site')
self.assertEqual(body['username'], 'testuser')
self.assertEqual(body['request_id'], str(request_id))
self.assertEqual(body['data']['name'], 'Site 1')
return HttpResponse()
# Enqueue a webhook for processing
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_webhooks(
instance=site,
user=self.user,
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
)
# Retrieve the job from queue
job = self.queue.jobs[0]
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
process_webhook(*job.args)

View File

@@ -8,38 +8,38 @@ app_name = 'extras'
urlpatterns = [
# Tags
path(r'tags/', views.TagListView.as_view(), name='tag_list'),
path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<slug:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts
path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
# Image attachments
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Change logging
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
# Reports
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
# Scripts
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
]

View File

@@ -37,7 +37,8 @@ class TagListView(PermissionRequiredMixin, ObjectListView):
template_name = 'extras/tag_list.html'
class TagView(View):
class TagView(PermissionRequiredMixin, View):
permission_required = 'extras.view_tag'
def get(self, request, slug):
@@ -84,10 +85,9 @@ class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
).order_by(
'name'
)
# filter = filters.ProviderFilter
table = TagTable
form = forms.TagBulkEditForm
default_return_url = 'circuits:provider_list'
default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):

View File

@@ -1,6 +1,9 @@
import datetime
import hashlib
import hmac
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from extras.models import Webhook
from utilities.api import get_serializer_for_model
@@ -8,6 +11,18 @@ from .choices import *
from .constants import *
def generate_signature(request_body, secret):
"""
Return a cryptographic signature that can be used to verify the authenticity of webhook data.
"""
hmac_prep = hmac.new(
key=secret.encode('utf8'),
msg=request_body.encode('utf8'),
digestmod=hashlib.sha512
)
return hmac_prep.hexdigest()
def enqueue_webhooks(instance, user, request_id, action):
"""
Find Webhook(s) assigned to this instance + action and enqueue them
@@ -48,7 +63,7 @@ def enqueue_webhooks(instance, user, request_id, action):
serializer.data,
instance._meta.model_name,
action,
str(datetime.datetime.now()),
str(timezone.now()),
user.username,
request_id
)

View File

@@ -1,5 +1,3 @@
import hashlib
import hmac
import json
import requests
@@ -7,6 +5,7 @@ from django_rq import job
from rest_framework.utils.encoders import JSONEncoder
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
from .webhooks import generate_signature
@job('default')
@@ -23,7 +22,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'data': data
}
headers = {
'Content-Type': webhook.get_http_content_type_display(),
'Content-Type': webhook.http_content_type,
}
if webhook.additional_headers:
headers.update(webhook.additional_headers)
@@ -43,12 +42,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
if webhook.secret != '':
# Sign the request with a hash of the secret key and its content.
hmac_prep = hmac.new(
key=webhook.secret.encode('utf8'),
msg=prepared_request.body.encode('utf8'),
digestmod=hashlib.sha512
)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
with requests.Session() as session:
session.verify = webhook.ssl_verification
@@ -56,7 +50,7 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
session.verify = webhook.ca_file_path
response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299:
if 200 <= response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
raise requests.exceptions.RequestException(

View File

@@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
@@ -237,20 +237,21 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceSerializer(CustomFieldModelSerializer):
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
serializer=NestedIPAddressSerializer,
required=False,
many=True
)
tags = TagListSerializerField(required=False)
class Meta:
model = Service
fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description',
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]

View File

@@ -15,30 +15,30 @@ router = routers.DefaultRouter()
router.APIRootView = IPAMRootView
# Field choices
router.register(r'_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.IPAMFieldChoicesViewSet, basename='field-choice')
# VRFs
router.register(r'vrfs', views.VRFViewSet)
router.register('vrfs', views.VRFViewSet)
# RIRs
router.register(r'rirs', views.RIRViewSet)
router.register('rirs', views.RIRViewSet)
# Aggregates
router.register(r'aggregates', views.AggregateViewSet)
router.register('aggregates', views.AggregateViewSet)
# Prefixes
router.register(r'roles', views.RoleViewSet)
router.register(r'prefixes', views.PrefixViewSet)
router.register('roles', views.RoleViewSet)
router.register('prefixes', views.PrefixViewSet)
# IP addresses
router.register(r'ip-addresses', views.IPAddressViewSet)
router.register('ip-addresses', views.IPAddressViewSet)
# VLANs
router.register(r'vlan-groups', views.VLANGroupViewSet)
router.register(r'vlans', views.VLANViewSet)
router.register('vlan-groups', views.VLANGroupViewSet)
router.register('vlans', views.VLANViewSet)
# Services
router.register(r'services', views.ServiceViewSet)
router.register('services', views.ServiceViewSet)
app_name = 'ipam-api'
urlpatterns = router.urls

View File

@@ -4,10 +4,34 @@ from .choices import IPAddressRoleChoices
BGP_ASN_MIN = 1
BGP_ASN_MAX = 2**32 - 1
#
# IP addresses
# VRFs
#
# Per RFC 4364 section 4.2, a route distinguisher may be encoded as one of the following:
# * Type 0 (16-bit AS number : 32-bit integer)
# * Type 1 (32-bit IPv4 address : 16-bit integer)
# * Type 2 (32-bit AS number : 16-bit integer)
# 21 characters are sufficient to convey the longest possible string value (255.255.255.255:65535)
VRF_RD_MAX_LENGTH = 21
#
# Prefixes
#
PREFIX_LENGTH_MIN = 1
PREFIX_LENGTH_MAX = 127 # IPv6
#
# IPAddresses
#
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6
IPADDRESS_ROLES_NONUNIQUE = (
# IPAddress roles which are exempt from unique address enforcement
IPAddressRoleChoices.ROLE_ANYCAST,
@@ -17,3 +41,21 @@ IPADDRESS_ROLES_NONUNIQUE = (
IPAddressRoleChoices.ROLE_GLBP,
IPAddressRoleChoices.ROLE_CARP,
)
#
# VLANs
#
# 12-bit VLAN ID (values 0 and 4095 are reserved)
VLAN_VID_MIN = 1
VLAN_VID_MAX = 4094
#
# Services
#
# 16-bit port number
SERVICE_PORT_MIN = 1
SERVICE_PORT_MAX = 65535

View File

@@ -2,13 +2,8 @@ from django.core.exceptions import ValidationError
from django.db import models
from netaddr import AddrFormatError, IPNetwork
from . import lookups
from .formfields import IPFormField
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
from . import lookups, validators
from .formfields import IPNetworkFormField
class BaseIPField(models.Field):
@@ -38,7 +33,7 @@ class BaseIPField(models.Field):
return str(self.to_python(value))
def form_class(self):
return IPFormField
return IPNetworkFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
@@ -51,7 +46,7 @@ class IPNetworkField(BaseIPField):
IP prefix (network and mask)
"""
description = "PostgreSQL CIDR field"
default_validators = [prefix_validator]
default_validators = [validators.prefix_validator]
def db_type(self, connection):
return 'cidr'

View File

@@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from utilities.filters import (
MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine
from .choices import *
@@ -304,12 +304,12 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
to_field_name='rd',
label='VRF (RD)',
)
device = django_filters.CharFilter(
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label='Device',
label='Device (name)',
)
device_id = django_filters.NumberFilter(
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label='Device (ID)',
@@ -385,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF
def filter_device(self, queryset, name, value):
try:
device = Device.objects.prefetch_related('device_type').get(**{name: value})
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()

View File

@@ -1,329 +0,0 @@
[
{
"model": "ipam.rir",
"pk": 1,
"fields": {
"name": "RFC1918",
"slug": "rfc1918"
}
},
{
"model": "ipam.aggregate",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.0.0.0/8",
"rir": 1,
"date_added": null,
"description": ""
}
},
{
"model": "ipam.role",
"pk": 1,
"fields": {
"name": "Lab Network",
"slug": "lab-network",
"weight": 1000
}
},
{
"model": "ipam.prefix",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.1.1.0/24",
"site": 1,
"vrf": null,
"vlan": null,
"status": "active",
"role": 1,
"description": ""
}
},
{
"model": "ipam.prefix",
"pk": 2,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"prefix": "10.0.255.0/24",
"site": 1,
"vrf": null,
"vlan": null,
"status": "active",
"role": 1,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.255.1/32",
"vrf": null,
"interface_id": 3,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 2,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.254.1/31",
"vrf": null,
"interface_id": 4,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 3,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.255.2/32",
"vrf": null,
"interface_id": 185,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 4,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 213,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 5,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.254.1/24",
"vrf": null,
"interface_id": 12,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 8,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.21.1/31",
"vrf": null,
"interface_id": 218,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 9,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.21.2/31",
"vrf": null,
"interface_id": 9,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 10,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.1/31",
"vrf": null,
"interface_id": 8,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 11,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.20.1/31",
"vrf": null,
"interface_id": 7,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 12,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.20.1/31",
"vrf": null,
"interface_id": 216,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 13,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 206,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 14,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.22.1/31",
"vrf": null,
"interface_id": 217,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 15,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.22.2/31",
"vrf": null,
"interface_id": 205,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 16,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.16.20.2/31",
"vrf": null,
"interface_id": 211,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 17,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.15.22.2/31",
"vrf": null,
"interface_id": 212,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 19,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "10.0.254.2/32",
"vrf": null,
"interface_id": 188,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 20,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.1/31",
"vrf": null,
"interface_id": 200,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.ipaddress",
"pk": 21,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"family": 4,
"address": "169.254.1.2/31",
"vrf": null,
"interface_id": 194,
"nat_inside": null,
"description": ""
}
},
{
"model": "ipam.vlan",
"pk": 1,
"fields": {
"created": "2016-06-23",
"last_updated": "2016-06-23T03:19:56.521Z",
"site": 1,
"vid": 999,
"name": "TEST",
"status": "active",
"role": 1
}
}
]

View File

@@ -1,13 +1,44 @@
from django import forms
from django.core.exceptions import ValidationError
from netaddr import IPNetwork, AddrFormatError
from django.core.validators import validate_ipv4_address, validate_ipv6_address
from netaddr import IPAddress, IPNetwork, AddrFormatError
#
# Form fields
#
class IPFormField(forms.Field):
class IPAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, IPAddress):
return value
# netaddr is a bit too liberal with what it accepts as a valid IP address. For example, '1.2.3' will become
# IPAddress('1.2.0.3'). Here, we employ Django's built-in IPv4 and IPv6 address validators as a sanity check.
try:
validate_ipv4_address(value)
except ValidationError:
try:
validate_ipv6_address(value)
except ValidationError:
raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
try:
return IPAddress(value)
except ValueError:
raise ValidationError('This field requires an IP address without a mask.')
except AddrFormatError:
raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
class IPNetworkFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
}

View File

@@ -4,33 +4,37 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField,
DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from .constants import *
from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
IP_FAMILY_CHOICES = [
('', 'All'),
(4, 'IPv4'),
(6, 'IPv6'),
]
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 128)])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([(i, i) for i in range(1, 129)])
PREFIX_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(PREFIX_LENGTH_MIN, PREFIX_LENGTH_MAX + 1)
])
IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
(i, i) for i in range(IPADDRESS_MASK_LENGTH_MIN, IPADDRESS_MASK_LENGTH_MAX + 1)
])
#
# VRFs
#
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = TagField(
required=False
)
@@ -48,7 +52,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
class VRFCSVForm(forms.ModelForm):
class VRFCSVForm(CustomFieldModelCSVForm):
tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
@@ -72,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm
queryset=VRF.objects.all(),
widget=forms.MultipleHiddenInput()
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -102,6 +106,7 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
label='Search'
)
tag = TagFilterField(model)
#
@@ -143,7 +148,13 @@ class RIRFilterForm(BootstrapMixin, forms.Form):
# Aggregates
#
class AggregateForm(BootstrapMixin, CustomFieldForm):
class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all(),
widget=APISelect(
api_url="/api/ipam/rirs/"
)
)
tags = TagField(
required=False
)
@@ -158,14 +169,11 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
'rir': "Regional Internet Registry responsible for this prefix",
}
widgets = {
'rir': APISelect(
api_url="/api/ipam/rirs/"
),
'date_added': DatePicker(),
}
class AggregateCSVForm(forms.ModelForm):
class AggregateCSVForm(CustomFieldModelCSVForm):
rir = forms.ModelChoiceField(
queryset=RIR.objects.all(),
to_field_name='name',
@@ -185,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
queryset=Aggregate.objects.all(),
widget=forms.MultipleHiddenInput()
)
rir = forms.ModelChoiceField(
rir = DynamicModelChoiceField(
queryset=RIR.objects.all(),
required=False,
label='RIR',
@@ -218,19 +226,21 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
rir = FilterChoiceField(
rir = DynamicModelMultipleChoiceField(
queryset=RIR.objects.all(),
to_field_name='slug',
required=False,
label='RIR',
widget=APISelectMultiple(
api_url="/api/ipam/rirs/",
value_field="slug",
)
)
tag = TagFilterField(model)
#
@@ -262,11 +272,17 @@ class RoleCSVForm(forms.ModelForm):
# Prefixes
#
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vrfs/",
)
)
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
widget=APISelect(
api_url="/api/dcim/sites/",
filter_for={
@@ -278,11 +294,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
)
)
vlan_group = ChainedModelChoiceField(
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='VLAN group',
widget=APISelect(
@@ -295,12 +308,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
)
)
vlan = ChainedModelChoiceField(
vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
chains=(
('site', 'site'),
('group', 'vlan_group'),
),
required=False,
label='VLAN',
widget=APISelect(
@@ -308,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
display_field='display_name'
)
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/roles/"
)
)
tags = TagField(required=False)
class Meta:
@@ -317,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'tags',
]
widgets = {
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
),
'status': StaticSelect2(),
'role': APISelect(
api_url="/api/ipam/roles/"
)
}
def __init__(self, *args, **kwargs):
@@ -340,7 +350,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global'
class PrefixCSVForm(forms.ModelForm):
class PrefixCSVForm(CustomFieldModelCSVForm):
vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(),
to_field_name='rd',
@@ -434,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Prefix.objects.all(),
widget=forms.MultipleHiddenInput()
)
site = forms.ModelChoiceField(
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
)
vrf = forms.ModelChoiceField(
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
@@ -450,11 +460,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
)
)
prefix_length = forms.IntegerField(
min_value=1,
max_value=127,
min_value=PREFIX_LENGTH_MIN,
max_value=PREFIX_LENGTH_MAX,
required=False
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -466,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
required=False,
widget=StaticSelect2()
)
role = forms.ModelChoiceField(
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
widget=APISelect(
@@ -510,7 +520,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
@@ -520,10 +530,10 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
label='Mask length',
widget=StaticSelect2()
)
vrf_id = FilterChoiceField(
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
null_label='-- Global --',
widget=APISelectMultiple(
api_url="/api/ipam/vrfs/",
null_option=True,
@@ -534,7 +544,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False,
widget=StaticSelect2Multiple()
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -546,20 +556,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
null_option=True,
)
)
role = FilterChoiceField(
role = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(),
to_field_name='slug',
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/roles/",
value_field="slug",
@@ -577,18 +587,27 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False,
label='Expand prefix hierarchy'
)
tag = TagFilterField(model)
#
# IP addresses
#
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
interface = forms.ModelChoiceField(
queryset=Interface.objects.all(),
required=False
)
nat_site = forms.ModelChoiceField(
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/"
)
)
nat_site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
label='Site',
@@ -600,11 +619,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
}
)
)
nat_rack = ChainedModelChoiceField(
nat_rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
chains=(
('site', 'nat_site'),
),
required=False,
label='Rack',
widget=APISelect(
@@ -618,12 +634,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
}
)
)
nat_device = ChainedModelChoiceField(
nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(),
chains=(
('site', 'nat_site'),
('rack', 'nat_rack'),
),
required=False,
label='Device',
widget=APISelect(
@@ -634,11 +646,19 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
}
)
)
nat_inside = ChainedModelChoiceField(
nat_vrf = forms.ModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/",
filter_for={
'nat_inside': 'vrf_id'
}
)
)
nat_inside = DynamicModelChoiceField(
queryset=IPAddress.objects.all(),
chains=(
('interface__device', 'nat_device'),
),
required=False,
label='IP Address',
widget=APISelect(
@@ -663,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
widgets = {
'status': StaticSelect2(),
'role': StaticSelect2(),
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
)
}
def __init__(self, *args, **kwargs):
@@ -739,7 +756,15 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form):
)
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
widget=APISelect(
api_url="/api/ipam/vrfs/"
)
)
class Meta:
model = IPAddress
@@ -749,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
widgets = {
'status': StaticSelect2(),
'role': StaticSelect2(),
'vrf': APISelect(
api_url="/api/ipam/vrfs/"
)
}
def __init__(self, *args, **kwargs):
@@ -759,7 +781,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['vrf'].empty_label = 'Global'
class IPAddressCSVForm(forms.ModelForm):
class IPAddressCSVForm(CustomFieldModelCSVForm):
vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(),
to_field_name='rd',
@@ -887,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
queryset=IPAddress.objects.all(),
widget=forms.MultipleHiddenInput()
)
vrf = forms.ModelChoiceField(
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
@@ -896,11 +918,11 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
)
)
mask_length = forms.IntegerField(
min_value=1,
max_value=128,
min_value=IPADDRESS_MASK_LENGTH_MIN,
max_value=IPADDRESS_MASK_LENGTH_MAX,
required=False
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -933,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf_id = forms.ModelChoiceField(
vrf_id = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
@@ -969,7 +991,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
)
family = forms.ChoiceField(
required=False,
choices=IP_FAMILY_CHOICES,
choices=add_blank_choice(IPAddressFamilyChoices),
label='Address family',
widget=StaticSelect2()
)
@@ -979,10 +1001,10 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
label='Mask length',
widget=StaticSelect2()
)
vrf_id = FilterChoiceField(
vrf_id = DynamicModelMultipleChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
null_label='-- Global --',
widget=APISelectMultiple(
api_url="/api/ipam/vrfs/",
null_option=True,
@@ -1005,6 +1027,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(model)
#
@@ -1012,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
#
class VLANGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
)
slug = SlugField()
class Meta:
@@ -1019,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm):
fields = [
'site', 'name', 'slug',
]
widgets = {
'site': APISelect(
api_url="/api/dcim/sites/"
)
}
class VLANGroupCSVForm(forms.ModelForm):
@@ -1047,7 +1072,7 @@ class VLANGroupCSVForm(forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -1059,10 +1084,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
null_label='-- Global --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
@@ -1075,8 +1100,8 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form):
# VLANs
#
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
site = forms.ModelChoiceField(
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
@@ -1089,17 +1114,20 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
)
)
group = ChainedModelChoiceField(
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
chains=(
('site', 'site'),
),
required=False,
label='Group',
widget=APISelect(
api_url='/api/ipam/vlan-groups/',
)
)
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/roles/"
)
)
tags = TagField(required=False)
class Meta:
@@ -1117,13 +1145,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldForm):
}
widgets = {
'status': StaticSelect2(),
'role': APISelect(
api_url="/api/ipam/roles/"
)
}
class VLANCSVForm(forms.ModelForm):
class VLANCSVForm(CustomFieldModelCSVForm):
site = forms.ModelChoiceField(
queryset=Site.objects.all(),
required=False,
@@ -1194,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
queryset=VLAN.objects.all(),
widget=forms.MultipleHiddenInput()
)
site = forms.ModelChoiceField(
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/sites/"
)
)
group = forms.ModelChoiceField(
group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
widget=APISelect(
api_url="/api/ipam/vlan-groups/"
)
)
tenant = forms.ModelChoiceField(
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
widget=APISelect(
@@ -1220,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
required=False,
widget=StaticSelect2()
)
role = forms.ModelChoiceField(
role = DynamicModelChoiceField(
queryset=Role.objects.all(),
required=False,
widget=APISelect(
@@ -1245,7 +1270,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
@@ -1258,20 +1283,20 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
}
)
)
site = FilterChoiceField(
site = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
null_label='-- Global --',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
null_option=True,
)
)
group_id = FilterChoiceField(
group_id = DynamicModelMultipleChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
label='VLAN group',
null_label='-- None --',
widget=APISelectMultiple(
api_url="/api/ipam/vlan-groups/",
null_option=True,
@@ -1282,26 +1307,27 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
required=False,
widget=StaticSelect2Multiple()
)
role = FilterChoiceField(
role = DynamicModelMultipleChoiceField(
queryset=Role.objects.all(),
to_field_name='slug',
null_label='-- None --',
required=False,
widget=APISelectMultiple(
api_url="/api/ipam/roles/",
value_field="slug",
null_option=True,
)
)
tag = TagFilterField(model)
#
# Services
#
class ServiceForm(BootstrapMixin, CustomFieldForm):
class ServiceForm(BootstrapMixin, CustomFieldModelForm):
port = forms.IntegerField(
min_value=1,
max_value=65535
min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
)
tags = TagField(
required=False
@@ -1352,6 +1378,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm):
port = forms.IntegerField(
required=False,
)
tag = TagFilterField(model)
class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
@@ -1378,5 +1405,5 @@ class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class Meta:
nullable_fields = [
'site', 'tenant', 'role', 'description',
'description',
]

View File

@@ -2,10 +2,10 @@ from django.db import migrations, models
IPADDRESS_STATUS_CHOICES = (
(0, 'container'),
(1, 'active'),
(2, 'reserved'),
(3, 'deprecated'),
(5, 'dhcp'),
)
IPADDRESS_ROLE_CHOICES = (

View File

@@ -0,0 +1,21 @@
from django.db import migrations
def ipaddress_status_dhcp_to_slug(apps, schema_editor):
IPAddress = apps.get_model('ipam', 'IPAddress')
IPAddress.objects.filter(status='5').update(status='dhcp')
class Migration(migrations.Migration):
dependencies = [
('ipam', '0033_deterministic_ordering'),
]
operations = [
# Fixes a missed integer substitution from #3569; see bug #4027. The original migration has also been fixed,
# so this can be omitted when squashing in the future.
migrations.RunPython(
code=ipaddress_status_dhcp_to_slug
),
]

View File

@@ -14,7 +14,7 @@ from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from .choices import *
from .constants import IPADDRESS_ROLES_NONUNIQUE
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .managers import IPAddressManager
from .querysets import PrefixQuerySet
@@ -44,7 +44,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
max_length=50
)
rd = models.CharField(
max_length=21,
max_length=VRF_RD_MAX_LENGTH,
unique=True,
blank=True,
null=True,
@@ -1006,7 +1006,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
choices=ServiceProtocolChoices
)
port = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(65535)],
validators=[MinValueValidator(SERVICE_PORT_MIN), MaxValueValidator(SERVICE_PORT_MAX)],
verbose_name='Port number'
)
ipaddresses = models.ManyToManyField(

View File

@@ -1064,6 +1064,7 @@ class ServiceTest(APITestCase):
'name': 'Test Service 4',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 4,
'tags': ['Foo', 'Bar'],
}
url = reverse('ipam-api:service-list')
@@ -1076,6 +1077,8 @@ class ServiceTest(APITestCase):
self.assertEqual(service4.name, data['name'])
self.assertEqual(service4.protocol, data['protocol'])
self.assertEqual(service4.port, data['port'])
tags = [tag.name for tag in service4.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
def test_create_service_bulk(self):

View File

@@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase):
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# TODO: Test for multiple values
def test_device(self):
device = Device.objects.first()
params = {'device_id': device.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'device': device.name}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine(self):
vms = VirtualMachine.objects.all()[:2]

View File

@@ -0,0 +1,176 @@
from django.test import TestCase
from ipam.choices import IPAddressStatusChoices, PrefixStatusChoices
from ipam.models import IPAddress, Prefix, VRF
import netaddr
class OrderingTestBase(TestCase):
vrfs = None
def setUp(self):
"""
Setup the VRFs for the class as a whole
"""
self.vrfs = (VRF(name="VRF A"), VRF(name="VRF B"), VRF(name="VRF C"))
VRF.objects.bulk_create(self.vrfs)
def _compare(self, queryset, objectset):
"""
Perform the comparison of the queryset object and the object used to instantiate the queryset.
"""
for i, obj in enumerate(queryset):
self.assertEqual(obj, objectset[i])
def _compare_ne(self, queryset, objectset):
"""
Perform the comparison of the queryset object and the object used to instantiate the queryset.
"""
for i, obj in enumerate(queryset):
self.assertNotEqual(obj, objectset[i])
class PrefixOrderingTestCase(OrderingTestBase):
def test_prefix_vrf_ordering(self):
"""
This is a very basic test, which tests both prefixes without VRFs and prefixes with VRFs
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Prefixes
prefixes = (
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.5.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.2.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/12')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.16.4.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.2.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.3.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, prefix=netaddr.IPNetwork('172.17.4.0/24')),
)
Prefix.objects.bulk_create(prefixes)
# Test
self._compare(Prefix.objects.all(), prefixes)
def test_prefix_complex_ordering(self):
"""
This function tests a complex ordering of interwoven prefixes and vrfs. This is the current expected ordering of VRFs
This includes the testing of the Container status.
The proper ordering, to get proper containerization should be:
None:10.0.0.0/8
None:10.0.0.0/16
VRF A:10.0.0.0/24
VRF A:10.0.1.0/24
VRF A:10.0.1.0/25
None:10.1.0.0/16
VRF A:10.1.0.0/24
VRF A:10.1.1.0/24
None: 192.168.0.0/16
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Prefixes
prefixes = [
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/8')),
Prefix(status=PrefixStatusChoices.STATUS_CONTAINER, vrf=None, family=4, prefix=netaddr.IPNetwork('10.0.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('10.1.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=None, family=4, prefix=netaddr.IPNetwork('192.168.0.0/16')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.0.1.0/25')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.0.0/24')),
Prefix(status=PrefixStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, prefix=netaddr.IPNetwork('10.1.1.0/24')),
]
Prefix.objects.bulk_create(prefixes)
# Test
self._compare(Prefix.objects.all(), prefixes)
class IPAddressOrderingTestCase(OrderingTestBase):
def test_address_vrf_ordering(self):
"""
This function tests ordering with the inclusion of vrfs
"""
# Setup VRFs
vrfa, vrfb, vrfc = self.vrfs
# Setup Addresses
addresses = (
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.0.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.1.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfa, family=4, address=netaddr.IPNetwork('10.2.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.16.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=vrfb, family=4, address=netaddr.IPNetwork('172.17.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.0.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.1.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.2.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.3.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.4.1/24')),
IPAddress(status=IPAddressStatusChoices.STATUS_ACTIVE, vrf=None, family=4, address=netaddr.IPNetwork('192.168.5.1/24')),
)
IPAddress.objects.bulk_create(addresses)
# Test
self._compare(IPAddress.objects.all(), addresses)

View File

@@ -1,26 +1,18 @@
from netaddr import IPNetwork
import urllib.parse
import datetime
from django.test import Client, TestCase
from django.urls import reverse
from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.choices import ServiceProtocolChoices
from ipam.choices import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from utilities.testing import create_test_user
from utilities.testing import ViewTestCases
class VRFTestCase(TestCase):
class VRFTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VRF
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_vrf',
'ipam.add_vrf',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
VRF.objects.bulk_create([
VRF(name='VRF 1', rd='65000:1'),
@@ -28,48 +20,34 @@ class VRFTestCase(TestCase):
VRF(name='VRF 3', rd='65000:3'),
])
def test_vrf_list(self):
url = reverse('ipam:vrf_list')
params = {
"q": "65000",
cls.form_data = {
'name': 'VRF X',
'rd': '65000:999',
'tenant': None,
'enforce_unique': True,
'description': 'A new VRF',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_vrf(self):
vrf = VRF.objects.first()
response = self.client.get(vrf.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_vrf_import(self):
csv_data = (
cls.csv_data = (
"name",
"VRF 4",
"VRF 5",
"VRF 6",
)
response = self.client.post(reverse('ipam:vrf_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(VRF.objects.count(), 6)
cls.bulk_edit_data = {
'tenant': None,
'enforce_unique': False,
'description': 'New description',
}
class RIRTestCase(TestCase):
class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = RIR
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_rir',
'ipam.add_rir',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
RIR.objects.bulk_create([
RIR(name='RIR 1', slug='rir-1'),
@@ -77,91 +55,66 @@ class RIRTestCase(TestCase):
RIR(name='RIR 3', slug='rir-3'),
])
def test_rir_list(self):
cls.form_data = {
'name': 'RIR X',
'slug': 'rir-x',
'is_private': True,
}
url = reverse('ipam:rir_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_rir_import(self):
csv_data = (
cls.csv_data = (
"name,slug",
"RIR 4,rir-4",
"RIR 5,rir-5",
"RIR 6,rir-6",
)
response = self.client.post(reverse('ipam:rir_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(RIR.objects.count(), 6)
class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Aggregate
@classmethod
def setUpTestData(cls):
class AggregateTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_aggregate',
'ipam.add_aggregate',
]
rirs = (
RIR(name='RIR 1', slug='rir-1'),
RIR(name='RIR 2', slug='rir-2'),
)
self.client = Client()
self.client.force_login(user)
rir = RIR(name='RIR 1', slug='rir-1')
rir.save()
RIR.objects.bulk_create(rirs)
Aggregate.objects.bulk_create([
Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rirs[0]),
Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rirs[0]),
Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rirs[0]),
])
def test_aggregate_list(self):
url = reverse('ipam:aggregate_list')
params = {
"rir": RIR.objects.first().slug,
cls.form_data = {
'family': 4,
'prefix': IPNetwork('10.99.0.0/16'),
'rir': rirs[1].pk,
'date_added': datetime.date(2020, 1, 1),
'description': 'A new aggregate',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_aggregate(self):
aggregate = Aggregate.objects.first()
response = self.client.get(aggregate.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_aggregate_import(self):
csv_data = (
cls.csv_data = (
"prefix,rir",
"10.4.0.0/16,RIR 1",
"10.5.0.0/16,RIR 1",
"10.6.0.0/16,RIR 1",
)
response = self.client.post(reverse('ipam:aggregate_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(Aggregate.objects.count(), 6)
cls.bulk_edit_data = {
'rir': rirs[1].pk,
'date_added': datetime.date(2020, 1, 1),
'description': 'New description',
}
class RoleTestCase(TestCase):
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_role',
'ipam.add_role',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
Role.objects.bulk_create([
Role(name='Role 1', slug='role-1'),
@@ -169,146 +122,135 @@ class RoleTestCase(TestCase):
Role(name='Role 3', slug='role-3'),
])
def test_role_list(self):
cls.form_data = {
'name': 'Role X',
'slug': 'role-x',
'weight': 200,
'description': 'A new role',
}
url = reverse('ipam:role_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_role_import(self):
csv_data = (
cls.csv_data = (
"name,slug,weight",
"Role 4,role-4,1000",
"Role 5,role-5,1000",
"Role 6,role-6,1000",
)
response = self.client.post(reverse('ipam:role_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(Role.objects.count(), 6)
class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Prefix
@classmethod
def setUpTestData(cls):
class PrefixTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_prefix',
'ipam.add_prefix',
]
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
self.client = Client()
self.client.force_login(user)
Site.objects.bulk_create(sites)
site = Site(name='Site 1', slug='site-1')
site.save()
vrfs = (
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
)
VRF.objects.bulk_create(vrfs)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
)
Prefix.objects.bulk_create([
Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]),
])
def test_prefix_list(self):
url = reverse('ipam:prefix_list')
params = {
"site": Site.objects.first().slug,
cls.form_data = {
'prefix': IPNetwork('192.0.2.0/24'),
'site': sites[1].pk,
'vrf': vrfs[1].pk,
'tenant': None,
'vlan': None,
'status': PrefixStatusChoices.STATUS_RESERVED,
'role': roles[1].pk,
'is_pool': True,
'description': 'A new prefix',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_prefix(self):
prefix = Prefix.objects.first()
response = self.client.get(prefix.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_prefix_import(self):
csv_data = (
cls.csv_data = (
"prefix,status",
"10.4.0.0/16,Active",
"10.5.0.0/16,Active",
"10.6.0.0/16,Active",
)
response = self.client.post(reverse('ipam:prefix_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(Prefix.objects.count(), 6)
class IPAddressTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_ipaddress',
'ipam.add_ipaddress',
]
)
self.client = Client()
self.client.force_login(user)
vrf = VRF(name='VRF 1', rd='65000:1')
vrf.save()
IPAddress.objects.bulk_create([
IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrf),
IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrf),
IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrf),
])
def test_ipaddress_list(self):
url = reverse('ipam:ipaddress_list')
params = {
"vrf": VRF.objects.first().rd,
cls.bulk_edit_data = {
'site': sites[1].pk,
'vrf': vrfs[1].pk,
'tenant': None,
'status': PrefixStatusChoices.STATUS_RESERVED,
'role': roles[1].pk,
'is_pool': False,
'description': 'New description',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_ipaddress(self):
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress
ipaddress = IPAddress.objects.first()
response = self.client.get(ipaddress.get_absolute_url())
self.assertEqual(response.status_code, 200)
@classmethod
def setUpTestData(cls):
def test_ipaddress_import(self):
vrfs = (
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
)
csv_data = (
IPAddress.objects.bulk_create([
IPAddress(family=4, address=IPNetwork('192.0.2.1/24'), vrf=vrfs[0]),
IPAddress(family=4, address=IPNetwork('192.0.2.2/24'), vrf=vrfs[0]),
IPAddress(family=4, address=IPNetwork('192.0.2.3/24'), vrf=vrfs[0]),
])
cls.form_data = {
'vrf': vrfs[1].pk,
'address': IPNetwork('192.0.2.99/24'),
'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST,
'interface': None,
'nat_inside': None,
'dns_name': 'example',
'description': 'A new IP address',
'tags': 'Alpha,Bravo,Charlie',
}
cls.csv_data = (
"address,status",
"192.0.2.4/24,Active",
"192.0.2.5/24,Active",
"192.0.2.6/24,Active",
)
response = self.client.post(reverse('ipam:ipaddress_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(IPAddress.objects.count(), 6)
cls.bulk_edit_data = {
'vrf': vrfs[1].pk,
'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST,
'dns_name': 'example',
'description': 'New description',
}
class VLANGroupTestCase(TestCase):
class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = VLANGroup
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_vlangroup',
'ipam.add_vlangroup',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
site = Site(name='Site 1', slug='site-1')
site.save()
site = Site.objects.create(name='Site 1', slug='site-1')
VLANGroup.objects.bulk_create([
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
@@ -316,104 +258,96 @@ class VLANGroupTestCase(TestCase):
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
])
def test_vlangroup_list(self):
url = reverse('ipam:vlangroup_list')
params = {
"site": Site.objects.first().slug,
cls.form_data = {
'name': 'VLAN Group X',
'slug': 'vlan-group-x',
'site': site.pk,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_vlangroup_import(self):
csv_data = (
cls.csv_data = (
"name,slug",
"VLAN Group 4,vlan-group-4",
"VLAN Group 5,vlan-group-5",
"VLAN Group 6,vlan-group-6",
)
response = self.client.post(reverse('ipam:vlangroup_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(VLANGroup.objects.count(), 6)
class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VLAN
@classmethod
def setUpTestData(cls):
class VLANTestCase(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'ipam.view_vlan',
'ipam.add_vlan',
]
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
self.client = Client()
self.client.force_login(user)
Site.objects.bulk_create(sites)
vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
vlangroup.save()
vlangroups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
)
VLANGroup.objects.bulk_create(vlangroups)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
)
Role.objects.bulk_create(roles)
VLAN.objects.bulk_create([
VLAN(group=vlangroup, vid=101, name='VLAN101'),
VLAN(group=vlangroup, vid=102, name='VLAN102'),
VLAN(group=vlangroup, vid=103, name='VLAN103'),
VLAN(group=vlangroups[0], vid=101, name='VLAN101', site=sites[0], role=roles[0]),
VLAN(group=vlangroups[0], vid=102, name='VLAN102', site=sites[0], role=roles[0]),
VLAN(group=vlangroups[0], vid=103, name='VLAN103', site=sites[0], role=roles[0]),
])
def test_vlan_list(self):
url = reverse('ipam:vlan_list')
params = {
"group": VLANGroup.objects.first().slug,
cls.form_data = {
'site': sites[1].pk,
'group': vlangroups[1].pk,
'vid': 999,
'name': 'VLAN999',
'tenant': None,
'status': VLANStatusChoices.STATUS_RESERVED,
'role': roles[1].pk,
'description': 'A new VLAN',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_vlan(self):
vlan = VLAN.objects.first()
response = self.client.get(vlan.get_absolute_url())
self.assertEqual(response.status_code, 200)
def test_vlan_import(self):
csv_data = (
cls.csv_data = (
"vid,name,status",
"104,VLAN104,Active",
"105,VLAN105,Active",
"106,VLAN106,Active",
)
response = self.client.post(reverse('ipam:vlan_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(VLAN.objects.count(), 6)
cls.bulk_edit_data = {
'site': sites[1].pk,
'group': vlangroups[1].pk,
'tenant': None,
'status': VLANStatusChoices.STATUS_RESERVED,
'role': roles[1].pk,
'description': 'New description',
}
class ServiceTestCase(TestCase):
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service
def setUp(self):
user = create_test_user(permissions=['ipam.view_service'])
self.client = Client()
self.client.force_login(user)
# Disable inapplicable tests
test_import_objects = None
site = Site(name='Site 1', slug='site-1')
site.save()
# TODO: Resolve URL for Service creation
test_create_object = None
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
@classmethod
def setUpTestData(cls):
devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device.save()
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
Service.objects.bulk_create([
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=101),
@@ -421,18 +355,19 @@ class ServiceTestCase(TestCase):
Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, port=103),
])
def test_service_list(self):
url = reverse('ipam:service_list')
params = {
"device_id": Device.objects.first(),
cls.form_data = {
'device': device.pk,
'virtual_machine': None,
'name': 'Service X',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'port': 999,
'ipaddresses': [],
'description': 'A new service',
'tags': 'Alpha,Bravo,Charlie',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_service(self):
service = Service.objects.first()
response = self.client.get(service.get_absolute_url())
self.assertEqual(response.status_code, 200)
cls.bulk_edit_data = {
'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
'port': 888,
'description': 'New description',
}

View File

@@ -8,97 +8,97 @@ app_name = 'ipam'
urlpatterns = [
# VRFs
path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'),
path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
path(r'vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
path(r'vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
path(r'vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
path(r'vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
path('vrfs/<int:pk>/', views.VRFView.as_view(), name='vrf'),
path('vrfs/<int:pk>/edit/', views.VRFEditView.as_view(), name='vrf_edit'),
path('vrfs/<int:pk>/delete/', views.VRFDeleteView.as_view(), name='vrf_delete'),
path('vrfs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}),
# RIRs
path(r'rirs/', views.RIRListView.as_view(), name='rir_list'),
path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path(r'rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
path(r'vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
path('rirs/', views.RIRListView.as_view(), name='rir_list'),
path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
path('vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
# Aggregates
path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
path(r'aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
path(r'aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path(r'aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
path(r'aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
# Roles
path(r'roles/', views.RoleListView.as_view(), name='role_list'),
path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'),
path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path(r'roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
path(r'roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
path('roles/', views.RoleListView.as_view(), name='role_list'),
path('roles/add/', views.RoleCreateView.as_view(), name='role_add'),
path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
path('roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
# Prefixes
path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
path(r'prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
path(r'prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
path(r'prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
path(r'prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
path(r'prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
path(r'prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
path('prefixes/<int:pk>/', views.PrefixView.as_view(), name='prefix'),
path('prefixes/<int:pk>/edit/', views.PrefixEditView.as_view(), name='prefix_edit'),
path('prefixes/<int:pk>/delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'),
path('prefixes/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}),
path('prefixes/<int:pk>/prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'),
path('prefixes/<int:pk>/ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'),
# IP addresses
path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
path(r'ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
path(r'ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
path(r'ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
path(r'ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
path('ip-addresses/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}),
path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
path('ip-addresses/<int:pk>/', views.IPAddressView.as_view(), name='ipaddress'),
path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
path('ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
# VLAN groups
path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path(r'vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
path(r'vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
path(r'vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
path('vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
# VLANs
path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'),
path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path(r'vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
path(r'vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
path(r'vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path(r'vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path(r'vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
# Services
path(r'services/', views.ServiceListView.as_view(), name='service_list'),
path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
path(r'services/<int:pk>/', views.ServiceView.as_view(), name='service'),
path(r'services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
path(r'services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
path(r'services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
path('services/', views.ServiceListView.as_view(), name='service_list'),
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
path('services/<int:pk>/', views.ServiceView.as_view(), name='service'),
path('services/<int:pk>/edit/', views.ServiceEditView.as_view(), name='service_edit'),
path('services/<int:pk>/delete/', views.ServiceDeleteView.as_view(), name='service_delete'),
path('services/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}),
]

View File

@@ -1,4 +1,26 @@
from django.core.validators import RegexValidator
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator, RegexValidator
def prefix_validator(prefix):
if prefix.ip != prefix.cidr.ip:
raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
class MaxPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be less than or equal to %(limit_value)s.'
code = 'max_prefix_length'
def compare(self, a, b):
return a.prefixlen > b
class MinPrefixLengthValidator(BaseValidator):
message = 'The prefix length must be greater than or equal to %(limit_value)s.'
code = 'min_prefix_length'
def compare(self, a, b):
return a.prefixlen < b
DNSValidator = RegexValidator(

View File

@@ -15,6 +15,7 @@ from utilities.views import (
from virtualization.models import VirtualMachine
from . import filters, forms, tables
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -86,23 +87,20 @@ def add_available_vlans(vlan_group, vlans):
"""
Create fake records for all gaps between used VLANs
"""
MIN_VLAN = 1
MAX_VLAN = 4094
if not vlans:
return [{'vid': MIN_VLAN, 'available': MAX_VLAN - MIN_VLAN + 1}]
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
prev_vid = MAX_VLAN
prev_vid = VLAN_VID_MAX
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
prev_vid = vlan.vid
if vlans[0].vid > MIN_VLAN:
new_vlans.append({'vid': MIN_VLAN, 'available': vlans[0].vid - MIN_VLAN})
if prev_vid < MAX_VLAN:
new_vlans.append({'vid': prev_vid + 1, 'available': MAX_VLAN - prev_vid})
if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])

View File

@@ -10,7 +10,8 @@
# Example: ALLOWED_HOSTS = ['netbox.example.com', 'netbox.internal.local']
ALLOWED_HOSTS = []
# PostgreSQL database configuration.
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASE = {
'NAME': 'netbox', # Database name
'USER': '', # PostgreSQL username
@@ -27,6 +28,9 @@ REDIS = {
'webhooks': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
@@ -35,6 +39,9 @@ REDIS = {
'caching': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '',
'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.7.2'
VERSION = '2.7.6'
# Hostname
HOSTNAME = platform.node()
@@ -74,6 +74,7 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
EMAIL = getattr(configuration, 'EMAIL', {})
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
@@ -169,18 +170,31 @@ if 'caching' not in REDIS:
WEBHOOKS_REDIS = REDIS.get('webhooks', {})
WEBHOOKS_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
WEBHOOKS_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
WEBHOOKS_REDIS_SENTINELS = WEBHOOKS_REDIS.get('SENTINELS', [])
WEBHOOKS_REDIS_USING_SENTINEL = all([
isinstance(WEBHOOKS_REDIS_SENTINELS, (list, tuple)),
len(WEBHOOKS_REDIS_SENTINELS) > 0
])
WEBHOOKS_REDIS_SENTINEL_SERVICE = WEBHOOKS_REDIS.get('SENTINEL_SERVICE', 'default')
WEBHOOKS_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
WEBHOOKS_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
WEBHOOKS_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
WEBHOOKS_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
CACHING_REDIS = REDIS.get('caching', {})
CACHING_REDIS_HOST = WEBHOOKS_REDIS.get('HOST', 'localhost')
CACHING_REDIS_PORT = WEBHOOKS_REDIS.get('PORT', 6379)
CACHING_REDIS_PASSWORD = WEBHOOKS_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = WEBHOOKS_REDIS.get('DATABASE', 0)
CACHING_REDIS_DEFAULT_TIMEOUT = WEBHOOKS_REDIS.get('DEFAULT_TIMEOUT', 300)
CACHING_REDIS_SSL = WEBHOOKS_REDIS.get('SSL', False)
CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost')
CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379)
CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', [])
CACHING_REDIS_USING_SENTINEL = all([
isinstance(CACHING_REDIS_SENTINELS, (list, tuple)),
len(CACHING_REDIS_SENTINELS) > 0
])
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
#
@@ -393,28 +407,35 @@ if LDAP_CONFIG is not None:
#
# Caching
#
if CACHING_REDIS_SSL:
REDIS_CACHE_CON_STRING = 'rediss://'
if CACHING_REDIS_USING_SENTINEL:
CACHEOPS_SENTINEL = {
'locations': CACHING_REDIS_SENTINELS,
'service_name': CACHING_REDIS_SENTINEL_SERVICE,
'db': CACHING_REDIS_DATABASE,
}
else:
REDIS_CACHE_CON_STRING = 'redis://'
if CACHING_REDIS_SSL:
REDIS_CACHE_CON_STRING = 'rediss://'
else:
REDIS_CACHE_CON_STRING = 'redis://'
if CACHING_REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
if CACHING_REDIS_PASSWORD:
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST,
CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE
)
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
REDIS_CACHE_CON_STRING,
CACHING_REDIS_HOST,
CACHING_REDIS_PORT,
CACHING_REDIS_DATABASE
)
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
if not CACHE_TIMEOUT:
CACHEOPS_ENABLED = False
else:
CACHEOPS_ENABLED = True
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
CACHEOPS_DEFAULTS = {
'timeout': CACHE_TIMEOUT
}
@@ -503,6 +524,7 @@ SWAGGER_SETTINGS = {
'utilities.custom_inspectors.IdInFilterInspector',
'drf_yasg.inspectors.CoreAPICompatInspector',
],
'DEFAULT_INFO': 'netbox.urls.openapi_info',
'DEFAULT_MODEL_DEPTH': 1,
'DEFAULT_PAGINATOR_INSPECTORS': [
'utilities.custom_inspectors.NullablePaginatorInspector',
@@ -532,6 +554,15 @@ RQ_QUEUES = {
'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': WEBHOOKS_REDIS_DEFAULT_TIMEOUT,
'SSL': WEBHOOKS_REDIS_SSL,
} if not WEBHOOKS_REDIS_USING_SENTINEL else {
'SENTINELS': WEBHOOKS_REDIS_SENTINELS,
'MASTER_NAME': WEBHOOKS_REDIS_SENTINEL_SERVICE,
'DB': WEBHOOKS_REDIS_DATABASE,
'PASSWORD': WEBHOOKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': {
'socket_connect_timeout': WEBHOOKS_REDIS_DEFAULT_TIMEOUT
},
}
}

View File

View File

@@ -0,0 +1,13 @@
from django.urls import reverse
from utilities.testing import APITestCase
class AppTest(APITestCase):
def test_root(self):
url = reverse('api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)

View File

@@ -0,0 +1,24 @@
import urllib.parse
from utilities.testing import TestCase
from django.urls import reverse
class HomeViewTestCase(TestCase):
def test_home(self):
url = reverse('home')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search(self):
url = reverse('search')
params = {
'q': 'foo',
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertHttpStatus(response, 200)

View File

@@ -9,14 +9,16 @@ from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView
from .admin import admin_site
openapi_info = openapi.Info(
title="NetBox API",
default_version='v2',
description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"),
)
schema_view = get_schema_view(
openapi.Info(
title="NetBox API",
default_version='v2',
description="API to access NetBox",
terms_of_service="https://github.com/netbox-community/netbox",
license=openapi.License(name="Apache v2 License"),
),
openapi_info,
validators=['flex', 'ssv'],
public=True,
)
@@ -24,49 +26,49 @@ schema_view = get_schema_view(
_patterns = [
# Base views
path(r'', HomeView.as_view(), name='home'),
path(r'search/', SearchView.as_view(), name='search'),
path('', HomeView.as_view(), name='home'),
path('search/', SearchView.as_view(), name='search'),
# Login/logout
path(r'login/', LoginView.as_view(), name='login'),
path(r'logout/', LogoutView.as_view(), name='logout'),
path('login/', LoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'),
# Apps
path(r'circuits/', include('circuits.urls')),
path(r'dcim/', include('dcim.urls')),
path(r'extras/', include('extras.urls')),
path(r'ipam/', include('ipam.urls')),
path(r'secrets/', include('secrets.urls')),
path(r'tenancy/', include('tenancy.urls')),
path(r'user/', include('users.urls')),
path(r'virtualization/', include('virtualization.urls')),
path('circuits/', include('circuits.urls')),
path('dcim/', include('dcim.urls')),
path('extras/', include('extras.urls')),
path('ipam/', include('ipam.urls')),
path('secrets/', include('secrets.urls')),
path('tenancy/', include('tenancy.urls')),
path('user/', include('users.urls')),
path('virtualization/', include('virtualization.urls')),
# API
path(r'api/', APIRootView.as_view(), name='api-root'),
path(r'api/circuits/', include('circuits.api.urls')),
path(r'api/dcim/', include('dcim.api.urls')),
path(r'api/extras/', include('extras.api.urls')),
path(r'api/ipam/', include('ipam.api.urls')),
path(r'api/secrets/', include('secrets.api.urls')),
path(r'api/tenancy/', include('tenancy.api.urls')),
path(r'api/virtualization/', include('virtualization.api.urls')),
path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
path('api/', APIRootView.as_view(), name='api-root'),
path('api/circuits/', include('circuits.api.urls')),
path('api/dcim/', include('dcim.api.urls')),
path('api/extras/', include('extras.api.urls')),
path('api/ipam/', include('ipam.api.urls')),
path('api/secrets/', include('secrets.api.urls')),
path('api/tenancy/', include('tenancy.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')),
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware
path(r'media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
# Admin
path(r'admin/', admin_site.urls),
path(r'admin/webhook-backend-status/', include('django_rq.urls')),
path('admin/', admin_site.urls),
path('admin/webhook-backend-status/', include('django_rq.urls')),
]
if settings.DEBUG:
import debug_toolbar
_patterns += [
path(r'__debug__/', include(debug_toolbar.urls)),
path('__debug__/', include(debug_toolbar.urls)),
]
if settings.METRICS_ENABLED:
@@ -76,7 +78,7 @@ if settings.METRICS_ENABLED:
# Prepend BASE_PATH
urlpatterns = [
path(r'{}'.format(settings.BASE_PATH), include(_patterns))
path('{}'.format(settings.BASE_PATH), include(_patterns))
]
handler500 = 'utilities.views.server_error'

View File

@@ -252,7 +252,7 @@ class HomeView(View):
'search_form': SearchForm(),
'stats': stats,
'report_results': ReportResult.objects.order_by('-created')[:10],
'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:50]
'changelog': ObjectChange.objects.prefetch_related('user', 'changed_object_type')[:15]
})

View File

@@ -62,8 +62,20 @@ footer p {
}
}
/* Scroll the drop-down menus at or above 768px wide to match bootstrap's behavior for hiding dropdown menus */
@media (min-width: 768px) {
.navbar-nav>li>ul {
max-height: calc(80vh - 50px);
overflow-y: auto;
}
}
/* Collapse the nav menu on displays less than 980px wide */
@media (max-width: 979px) {
#navbar {
max-height: calc(80vh - 50px);
overflow-y: auto;
}
.navbar-header {
float: none;
}

View File

@@ -56,3 +56,12 @@ text {
.blocked:hover+.add-device {
fill: none;
}
.unit {
margin: 0;
padding: 5px 0px;
fill: #c0c0c0;
font-size: 10px;
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
}

View File

@@ -0,0 +1,11 @@
$('.rendered-context-format').on('click', function() {
if (!$(this).hasClass('active')) {
// Update selection in the button group
$('span.rendered-context-format').removeClass('active');
$('span.rendered-context-format[data-format=' + $(this).data('format') + ']').addClass('active');
// Hide all rendered contexts and only show the selected one
$('div.rendered-context-data').hide();
$('div.rendered-context-data[data-format=' + $(this).data('format') + ']').show();
}
});

View File

@@ -158,14 +158,17 @@ $(document).ready(function() {
filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name);
var is_required = $(filter_for_element).attr("required");
var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val();
if (param_name && is_visible && value) {
parameters[param_name] = value;
} else if (param_name && is_visible && is_nullable) {
parameters[param_name] = "null";
if (param_name && is_visible) {
if (value) {
parameters[param_name] = value;
} else if (is_required && is_nullable) {
parameters[param_name] = "null";
}
}
});
@@ -217,19 +220,19 @@ $(document).ready(function() {
}
if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) {
results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }
results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] };
results[record.site.name + ":" + record.group.name].children.push(record);
}
else if( record.group !== undefined && record.group !== null ) {
results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }
results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] };
results[record.group.name].children.push(record);
}
else if( record.site !== undefined && record.site !== null ) {
results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }
results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] };
results[record.site.name].children.push(record);
}
else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) {
results['global'] = results['global'] || { text: 'Global', children: [] }
results['global'] = results['global'] || { text: 'Global', children: [] };
results['global'].children.push(record);
}
else {
@@ -243,10 +246,9 @@ $(document).ready(function() {
// Handle the null option, but only add it once
if (element.getAttribute('data-null-option') && data.previous === null) {
var null_option = $(element).children()[0];
results.unshift({
id: null_option.value,
text: null_option.text
id: 'null',
text: 'None'
});
}

View File

@@ -2,9 +2,9 @@
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddresses').hide();
$('#interfaces_table tr.interface:visible + tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddresses').show();
$('#interfaces_table tr.interface:visible + tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
@@ -14,17 +14,22 @@ $('button.toggle-ips').click(function() {
// Inteface filtering
$('input.interface-filter').on('input', function() {
var filter = new RegExp(this.value);
var interface;
for (interface of $(this).closest('div.panel').find('tbody > tr')) {
for (interface of $('#interfaces_table > tbody > tr.interface')) {
// Slice off 'interface_' at the start of the ID
if (filter && filter.test(interface.id.slice(10))) {
if (filter.test(interface.id.slice(10))) {
// Match the toggle in case the filter now matches the interface
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
$(interface).show();
if ($('button.toggle-ips').attr('selected')) {
$(interface).next('tr.ipaddresses').show();
}
} else {
// Uncheck to prevent actions from including it when it doesn't match
$(interface).find('input:checkbox[name=pk]').prop('checked', false);
$(interface).hide();
$(interface).next('tr.ipaddresses').hide();
}
}
});

View File

@@ -15,15 +15,15 @@ router = routers.DefaultRouter()
router.APIRootView = SecretsRootView
# Field choices
router.register(r'_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
router.register('_choices', views.SecretsFieldChoicesViewSet, basename='field-choice')
# Secrets
router.register(r'secret-roles', views.SecretRoleViewSet)
router.register(r'secrets', views.SecretViewSet)
router.register('secret-roles', views.SecretRoleViewSet)
router.register('secrets', views.SecretViewSet)
# Miscellaneous
router.register(r'get-session-key', views.GetSessionKeyViewSet, basename='get-session-key')
router.register(r'generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair')
router.register('get-session-key', views.GetSessionKeyViewSet, basename='get-session-key')
router.register('generate-rsa-key-pair', views.GenerateRSAKeyPairViewSet, basename='generate-rsa-key-pair')
app_name = 'secrets-api'
urlpatterns = router.urls

View File

@@ -93,8 +93,8 @@ class SecretViewSet(ModelViewSet):
secret = self.get_object()
# Attempt to decrypt the secret if the master key is known
if self.master_key is not None:
# Attempt to decrypt the secret if the user is permitted and the master key is known
if secret.decryptable_by(request.user) and self.master_key is not None:
secret.decrypt(self.master_key)
serializer = self.get_serializer(secret)
@@ -111,7 +111,9 @@ class SecretViewSet(ModelViewSet):
if self.master_key is not None:
secrets = []
for secret in page:
secret.decrypt(self.master_key)
# Enforce role permissions
if secret.decryptable_by(request.user):
secret.decrypt(self.master_key)
secrets.append(secret)
serializer = self.get_serializer(secrets, many=True)
else:

View File

@@ -0,0 +1,5 @@
#
# Secrets
#
SECRET_PLAINTEXT_MAX_LENGTH = 65535

View File

@@ -4,11 +4,14 @@ from django import forms
from taggit.forms import TagField
from dcim.models import Device
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldForm
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField,
StaticSelect2Multiple
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
)
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField,
)
from .constants import *
from .models import Secret, SecretRole, UserKey
@@ -67,9 +70,9 @@ class SecretRoleCSVForm(forms.ModelForm):
# Secrets
#
class SecretForm(BootstrapMixin, CustomFieldForm):
class SecretForm(BootstrapMixin, CustomFieldModelForm):
plaintext = forms.CharField(
max_length=65535,
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False,
label='Plaintext',
widget=forms.PasswordInput(
@@ -79,11 +82,17 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
)
)
plaintext2 = forms.CharField(
max_length=65535,
max_length=SECRET_PLAINTEXT_MAX_LENGTH,
required=False,
label='Plaintext (verify)',
widget=forms.PasswordInput()
)
role = DynamicModelChoiceField(
queryset=SecretRole.objects.all(),
widget=APISelect(
api_url="/api/secrets/secret-roles/"
)
)
tags = TagField(
required=False
)
@@ -93,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
fields = [
'role', 'name', 'plaintext', 'plaintext2', 'tags',
]
widgets = {
'role': APISelect(
api_url="/api/secrets/secret-roles/"
)
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -115,7 +119,7 @@ class SecretForm(BootstrapMixin, CustomFieldForm):
})
class SecretCSVForm(forms.ModelForm):
class SecretCSVForm(CustomFieldModelCSVForm):
device = FlexibleModelChoiceField(
queryset=Device.objects.all(),
to_field_name='name',
@@ -154,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
queryset=Secret.objects.all(),
widget=forms.MultipleHiddenInput()
)
role = forms.ModelChoiceField(
role = DynamicModelChoiceField(
queryset=SecretRole.objects.all(),
required=False,
widget=APISelect(
@@ -178,14 +182,16 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
role = FilterChoiceField(
role = DynamicModelMultipleChoiceField(
queryset=SecretRole.objects.all(),
to_field_name='slug',
required=True,
widget=APISelectMultiple(
api_url="/api/secrets/secret-roles/",
value_field="slug",
)
)
tag = TagFilterField(model)
#

View File

@@ -5,7 +5,8 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.testing import APITestCase
from users.models import Token
from utilities.testing import APITestCase, create_test_user
from .constants import PRIVATE_KEY, PUBLIC_KEY
@@ -131,7 +132,15 @@ class SecretTest(APITestCase):
def setUp(self):
super().setUp()
# Create a non-superuser test user
self.user = create_test_user('testuser', permissions=(
'secrets.add_secret',
'secrets.change_secret',
'secrets.delete_secret',
'secrets.view_secret',
))
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
@@ -144,11 +153,11 @@ class SecretTest(APITestCase):
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
}
self.plaintext = {
'secret1': 'Secret #1 Plaintext',
'secret2': 'Secret #2 Plaintext',
'secret3': 'Secret #3 Plaintext',
}
self.plaintexts = (
'Secret #1 Plaintext',
'Secret #2 Plaintext',
'Secret #3 Plaintext',
)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
@@ -160,17 +169,17 @@ class SecretTest(APITestCase):
self.secretrole1 = SecretRole.objects.create(name='Test Secret Role 1', slug='test-secret-role-1')
self.secretrole2 = SecretRole.objects.create(name='Test Secret Role 2', slug='test-secret-role-2')
self.secret1 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintext['secret1']
device=self.device, role=self.secretrole1, name='Test Secret 1', plaintext=self.plaintexts[0]
)
self.secret1.encrypt(self.master_key)
self.secret1.save()
self.secret2 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintext['secret2']
device=self.device, role=self.secretrole1, name='Test Secret 2', plaintext=self.plaintexts[1]
)
self.secret2.encrypt(self.master_key)
self.secret2.save()
self.secret3 = Secret(
device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintext['secret3']
device=self.device, role=self.secretrole1, name='Test Secret 3', plaintext=self.plaintexts[2]
)
self.secret3.encrypt(self.master_key)
self.secret3.save()
@@ -178,16 +187,32 @@ class SecretTest(APITestCase):
def test_get_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['plaintext'], self.plaintext['secret1'])
# Secret plaintext not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
self.assertIsNone(response.data['plaintext'])
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['plaintext'], self.plaintexts[0])
def test_list_secrets(self):
url = reverse('secrets-api:secret-list')
response = self.client.get(url, **self.header)
# Secret plaintext not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
for secret in response.data['results']:
self.assertIsNone(secret['plaintext'])
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
for i, secret in enumerate(response.data['results']):
self.assertEqual(secret['plaintext'], self.plaintexts[i])
def test_create_secret(self):

View File

@@ -29,5 +29,4 @@ class UserKeyFormTestCase(TestCase):
data={'public_key': SSH_PUBLIC_KEY},
instance=self.userkey,
)
print(form.is_valid())
self.assertFalse(form.is_valid())

View File

@@ -1,26 +1,18 @@
import base64
import urllib.parse
from django.test import Client, TestCase
from django.urls import reverse
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from utilities.testing import create_test_user
from utilities.testing import ViewTestCases
from .constants import PRIVATE_KEY, PUBLIC_KEY
class SecretRoleTestCase(TestCase):
class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = SecretRole
def setUp(self):
user = create_test_user(
permissions=[
'secrets.view_secretrole',
'secrets.add_secretrole',
]
)
self.client = Client()
self.client.force_login(user)
@classmethod
def setUpTestData(cls):
SecretRole.objects.bulk_create([
SecretRole(name='Secret Role 1', slug='secret-role-1'),
@@ -28,89 +20,83 @@ class SecretRoleTestCase(TestCase):
SecretRole(name='Secret Role 3', slug='secret-role-3'),
])
def test_secretrole_list(self):
cls.form_data = {
'name': 'Secret Role X',
'slug': 'secret-role-x',
'description': 'A secret role',
'users': [],
'groups': [],
}
url = reverse('secrets:secretrole_list')
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200)
def test_secretrole_import(self):
csv_data = (
cls.csv_data = (
"name,slug",
"Secret Role 4,secret-role-4",
"Secret Role 5,secret-role-5",
"Secret Role 6,secret-role-6",
)
response = self.client.post(reverse('secrets:secretrole_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertEqual(SecretRole.objects.count(), 6)
class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Secret
# Disable inapplicable tests
test_create_object = None
class SecretTestCase(TestCase):
# TODO: Check permissions enforcement on secrets.views.secret_edit
test_edit_object = None
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
)
Device.objects.bulk_create(devices)
secretroles = (
SecretRole(name='Secret Role 1', slug='secret-role-1'),
SecretRole(name='Secret Role 2', slug='secret-role-2'),
)
SecretRole.objects.bulk_create(secretroles)
# Create one secret per device to allow bulk-editing of names (which must be unique per device/role)
Secret.objects.bulk_create((
Secret(device=devices[0], role=secretroles[0], name='Secret 1', ciphertext=b'1234567890'),
Secret(device=devices[1], role=secretroles[0], name='Secret 2', ciphertext=b'1234567890'),
Secret(device=devices[2], role=secretroles[0], name='Secret 3', ciphertext=b'1234567890'),
))
cls.form_data = {
'device': devices[1].pk,
'role': secretroles[1].pk,
'name': 'Secret X',
}
cls.bulk_edit_data = {
'role': secretroles[1].pk,
'name': 'New name',
}
def setUp(self):
user = create_test_user(
permissions=[
'secrets.view_secret',
'secrets.add_secret',
]
)
# Set up a master key
userkey = UserKey(user=user, public_key=PUBLIC_KEY)
super().setUp()
# Set up a master key for the test user
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
master_key = userkey.get_master_key(PRIVATE_KEY)
self.session_key = SessionKey(userkey=userkey)
self.session_key.save(master_key)
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device.save()
secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1')
secretrole.save()
Secret.objects.bulk_create([
Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'),
Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'),
])
def test_secret_list(self):
url = reverse('secrets:secret_list')
params = {
"role": SecretRole.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
self.assertEqual(response.status_code, 200)
def test_secret(self):
secret = Secret.objects.first()
response = self.client.get(secret.get_absolute_url(), follow=True)
self.assertEqual(response.status_code, 200)
def test_secret_import(self):
def test_import_objects(self):
self.add_permissions('secrets.add_secret')
csv_data = (
"device,role,name,plaintext",
@@ -125,5 +111,5 @@ class SecretTestCase(TestCase):
response = self.client.post(reverse('secrets:secret_import'), {'csv': '\n'.join(csv_data)})
self.assertEqual(response.status_code, 200)
self.assertHttpStatus(response, 200)
self.assertEqual(Secret.objects.count(), 6)

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