Compare commits

...

120 Commits

Author SHA1 Message Date
Jeremy Stretch
1c5af01a82 Merge pull request #4816 from netbox-community/develop
Release v2.8.7
2020-07-02 09:42:41 -04:00
Jeremy Stretch
9f614452b4 Release NetBox v2.8.7 2020-07-02 09:37:20 -04:00
Jeremy Stretch
43d610405f Add changelog for #4695 and #4708 2020-07-01 11:06:49 -04:00
Jeremy Stretch
7e8a4a2a77 Merge pull request #4797 from netbox-community/4695_fix_api_cable_choices_termination_types
Fixes #4695 - Add Metadata class that returns content type choices
2020-07-01 11:03:01 -04:00
Jeremy Stretch
56ec4a6360 Merge pull request #4706 from netbox-community/4604_check_position_stack
4708: more flexible checks on RearPort usage
2020-07-01 10:59:17 -04:00
Jeremy Stretch
0b1df1483f Automatically import ContentType when entering nbshell 2020-06-30 16:34:53 -04:00
Jeremy Stretch
7defa22b0b Remove reference to choices API endpoints 2020-06-30 15:55:15 -04:00
Jeremy Stretch
52cff1ee50 Fixes #4771: Fix add/remove tag population when bulk editing objects 2020-06-30 09:55:54 -04:00
Jeremy Stretch
8a26f475a7 Fixes #4774: Fix exception when deleting a device with device bays 2020-06-30 09:43:05 -04:00
Jeremy Stretch
51e9b0a22a Closes #4796: Introduce configuration parameters for default rack elevation size 2020-06-30 09:26:32 -04:00
Jeremy Stretch
268b4c854e Closes #4802: Allow changing page size when displaying only a single page of results 2020-06-30 09:00:42 -04:00
Ryan Merolle
c8461095c9 add missing NEMA power ports/outlets (#4784)
* add various NEMA power ports/outlets
2020-06-26 15:34:38 -04:00
Jeremy Stretch
5dfa80c0b9 Fix the initial permissions check on create/edit/delete view tests 2020-06-26 15:17:07 -04:00
Sander Steffann
b26fc81187 Sort the list for consistent output 2020-06-26 18:42:08 +02:00
Sander Steffann
0455947597 Make sure that the endpoint is actually a CableTermination 2020-06-26 18:24:04 +02:00
Daniel Sheppard
8179cfa4c1 #4695 - Rename LimitedMetaData to ContentTypeMetadata 2020-06-26 11:09:27 -05:00
Daniel Sheppard
d21881e207 #4695 - Add Metadata class that returns content type choices 2020-06-26 10:59:21 -05:00
Sander Steffann
25926e32f0 Replace is_connected_endpoint with simple isinstance check
It was only used in a single location anyway…
2020-06-26 17:30:59 +02:00
Sander Steffann
3fdc8e7d3d Replace is_path_endpoint with simple isinstance check
It was only used in a single location anyway…
2020-06-26 17:25:07 +02:00
Jeremy Stretch
71afba4d2e Fixes #4791: Update custom script documentation for ObjectVar 2020-06-25 17:33:41 -04:00
Sander Steffann
ed1717f858 Revert "Bumping version just to test the GitHub Action"
This reverts commit 1cf0868e
2020-06-24 13:09:11 +02:00
Sander Steffann
1cf0868e30 Bumping version just to test the GitHub Action 2020-06-24 13:07:54 +02:00
Jeremy Stretch
462f992a2b Introduce ComponentCreateForm to standardize forms for device component creation 2020-06-18 12:09:28 -04:00
Jeremy Stretch
c5dc075fb0 Fixes #4775: Allow selecting an alternate device type when creating component templates 2020-06-18 11:59:24 -04:00
Jeremy Stretch
0800279325 Standardize SecretTest 2020-06-17 15:37:28 -04:00
Jeremy Stretch
26770515e1 Refactor TestCase to provide model_to_dict(), prepare_instance() 2020-06-17 15:36:56 -04:00
Jeremy Stretch
b0c24de596 Fixes #4772: Fix "brief" format for the secrets REST API endpoint 2020-06-17 14:22:55 -04:00
Sander Steffann
715ddc6b02 Define is_path_endpoint and is_connected_endpoint separately, as a CableTermination is a possible connected endpoint but not always the end of the path. 2020-06-17 17:11:28 +02:00
Jeremy Stretch
e23a5ad141 Fixes #4766: Fix redirect after login when next is not specified 2020-06-17 09:15:03 -04:00
Sander Steffann
3876efe494 Fix is_path_endpoint flag on CableTermination 2020-06-16 21:56:46 +02:00
Sander Steffann
f075339c5f Improve test comments and remove over-enthusiastic tests 2020-06-16 21:48:26 +02:00
Sander Steffann
abaf0daa6e Store the front ports on the position_stack so we can provide better feedback to the user 2020-06-16 21:47:37 +02:00
Sander Steffann
4a11800d9e Better comments 2020-06-16 21:47:10 +02:00
Sander Steffann
cafecb091d Replace temporary comment with proper one 2020-06-16 21:46:16 +02:00
Jeremy Stretch
7cf0e6034b Optimize tag population under prepare_cloned_fields() 2020-06-16 15:12:50 -04:00
Jeremy Stretch
a5512dd4c4 Post-release version bump 2020-06-15 14:57:05 -04:00
Jeremy Stretch
bac3ace8fc Merge pull request #4762 from netbox-community/develop
Release v2.8.6
2020-06-15 14:45:01 -04:00
Jeremy Stretch
60deb3f0ba Release v2.8.6 2020-06-15 14:37:36 -04:00
Jeremy Stretch
eaaaaec5a5 Fixes #4710: Fix merging of form fields among custom scripts 2020-06-15 14:20:00 -04:00
Jeremy Stretch
5bcf85e57d Closes #4744: Hide IP addresses tab when viewing a container prefix 2020-06-15 13:33:16 -04:00
Jeremy Stretch
1d466d6fd1 Closes #4761: Enable tag assignment during bulk creation of IP addresses 2020-06-15 13:24:34 -04:00
Jeremy Stretch
57cfb4ed7e Fixes #4760: Enable power port template assignment when bulk editing power outlet templates 2020-06-15 13:18:26 -04:00
Jeremy Stretch
9fa4cbdfa5 Correction for #4756 2020-06-15 12:43:08 -04:00
Jeremy Stretch
5af2b3c2f5 Closes #4717: Introduce ALLOWED_URL_SCHEMES configuration parameter to mitigate dangerous hyperlinks 2020-06-15 11:53:47 -04:00
Jeremy Stretch
2e5058c4c9 Fixes #4756: Filter parent group by site when creating rack groups 2020-06-15 10:02:35 -04:00
Jeremy Stretch
9fc4a4f24a Closes #4755: Enable creation of rack reservations directly from navigation menu 2020-06-12 15:11:27 -04:00
Jeremy Stretch
9fd36279ab Fixes #4743: Allow users to create "next available" IPs without needing permission to create prefixes 2020-06-10 16:06:11 -04:00
Jeremy Stretch
40947f8cb2 Merge pull request #4734 from tyler-8/bulk_api_docs
Add example of bulk object creation in documentation
2020-06-10 11:39:44 -04:00
Jeremy Stretch
9abc67bbeb Fixes #4737: Introduce ColoredLabelColumn for consistent display of colored labels 2020-06-10 11:38:23 -04:00
Jeremy Stretch
16cdf3006f Fixes #4736: Add cable trace endpoints for pass-through ports 2020-06-09 15:12:10 -04:00
Jeremy Stretch
15004c654f Add missing API cable trace test for interfaces 2020-06-09 14:47:05 -04:00
Tyler Bigler
062a319a7c Add example of bulk object creation 2020-06-09 13:35:44 -04:00
Jeremy Stretch
ed9ca270a7 Add missing API tests for pass-through port templates 2020-06-09 13:24:07 -04:00
Jeremy Stretch
20ec700045 Changelog for #4674 2020-06-08 17:00:47 -04:00
Jeremy Stretch
ecd3963b7c Merge pull request #4718 from netbox-community/4674-drf_yasg_definitions
Fixes #4674 - Fix available-ips and available-prefixes swagger definitions
2020-06-08 16:59:04 -04:00
Jeremy Stretch
1ea368856b Merge pull request #4728 from netbox-community/4722-api-tests
Closes #4722: Standardize API view tests
2020-06-08 10:16:10 -04:00
Jeremy Stretch
a8077e6ed1 Extend assertInstanceEqual() to accommodate REST API data 2020-06-08 09:47:14 -04:00
Jeremy Stretch
7def37961a Correct exempted test methods on InterfaceTestCase 2020-06-05 16:17:10 -04:00
Jeremy Stretch
4f830c9c22 Fix list_brief tests 2020-06-05 16:09:55 -04:00
Jeremy Stretch
032f87caec Merge branch 'develop' into 4722-api-tests 2020-06-05 15:50:14 -04:00
Jeremy Stretch
e616aad911 Fixes #4725: Fix "brief" rendering of various REST API endpoints 2020-06-05 15:49:06 -04:00
Jeremy Stretch
c2f6f5a7cd Fix ProviderTest 2020-06-05 15:18:18 -04:00
Jeremy Stretch
d3fbaca228 Standardize virtualization API tests 2020-06-05 15:06:08 -04:00
Jeremy Stretch
ae913f14ce Standardize tenancy API tests 2020-06-05 14:30:01 -04:00
Jeremy Stretch
1ee79ee61e Standardize SecretRoleTest 2020-06-05 14:18:38 -04:00
Jeremy Stretch
b5ebfd0b07 Standardize IPAM API tests 2020-06-05 14:09:54 -04:00
Jeremy Stretch
665646707c Standardize extras API tests 2020-06-05 13:41:54 -04:00
Jeremy Stretch
279ae7ea10 Standardize DCIM API tests 2020-06-05 13:23:33 -04:00
Jeremy Stretch
8cc1dc9f1c Fix update data 2020-06-05 10:05:54 -04:00
Jeremy Stretch
86e5a09b01 Optimize test_get_provider_graphs() 2020-06-05 09:36:38 -04:00
Jeremy Stretch
1d5f2fbd11 Correct test method name 2020-06-05 09:19:31 -04:00
Jeremy Stretch
4219691e62 Update circuits API tests to use APIViewTestCases 2020-06-04 16:47:15 -04:00
Jeremy Stretch
4ae1879b87 Introduce APIViewTestCases for standardized API view testing 2020-06-04 16:45:03 -04:00
Jeremy Stretch
d2dce6db25 Merge pull request #4719 from netbox-community/4715-avoid-unnecessary-queries
Fixes #4715: Avoid unnecessary queries in Cable.from_db
2020-06-04 13:13:17 -04:00
Jeremy Stretch
fae115b995 Closes #4698: Improve display of template code for object in admin UI 2020-06-04 13:11:24 -04:00
Sander Steffann
8f9dcf5a97 Avoid unnecessary queries in Cable.from_db 2020-06-04 17:46:09 +02:00
Jeremy Stretch
91ba44cc96 Add local_requirements.txt to .gitignore 2020-06-04 11:44:16 -04:00
Daniel Sheppard
5330914431 #4674 - Correct many=False to many=True on the response serializers 2020-06-04 09:42:00 -05:00
Daniel Sheppard
927c012fc9 #4674 - Fix available-ips and available-prefixes swagger definitions 2020-06-04 09:35:58 -05:00
Jeremy Stretch
56f6698ba5 Fixes #4707: Fix prefix_count population on VLAN API serializer 2020-06-02 13:40:14 -04:00
Sander Steffann
886b59f400 Update tests for cables 2020-06-02 13:14:51 +02:00
Sander Steffann
8bd9b460cb Only complete path when there are not split_ends or position_stack 2020-06-02 13:14:38 +02:00
Sander Steffann
34ae57dfa3 Show warning when position stack is not empty after trace 2020-06-02 13:13:41 +02:00
Sander Steffann
81a322eaaf Add position_stack to returned values from trace() 2020-06-02 13:13:10 +02:00
Sander Steffann
2479b8a57f Validate against is_path_endpoint instead of specific classes, and only when positions > 1 2020-06-02 13:11:35 +02:00
Jeremy Stretch
2fe4656db4 Permit connection of a multi-position RearPort to a FrontPort 2020-06-02 12:03:02 +02:00
Jeremy Stretch
6fc7c6a7d0 Update path validation tests for single-position rear port scenarios 2020-06-02 12:03:02 +02:00
Jeremy Stretch
1d33d7d205 Call full_clean() when saving Cable instances 2020-06-02 12:03:02 +02:00
Sander Steffann
56898f7e37 Restore original test_connection_via_single_rear_port test and make separate test for one-on-one panels 2020-06-02 12:03:02 +02:00
Sander Steffann
3278cc8cc0 Recreate the model instance instead of re-saving a deleted model
Same end result, but easier to read
2020-06-02 12:03:02 +02:00
Sander Steffann
112dfb865b Integrate patch panel building into one list 2020-06-02 12:03:02 +02:00
Sander Steffann
a0f4d481dc make single front/rear port work when between panels 2020-06-02 12:03:02 +02:00
Jeremy Stretch
edf15532d2 Fixes #4702: Catch IntegrityError exception when adding a non-unique secret 2020-06-01 10:00:32 -04:00
Jeremy Stretch
d23b18beb5 Fixes #4704: Update example template code 2020-06-01 09:40:58 -04:00
Jeremy Stretch
56b7ab1734 Post-release version bump 2020-05-26 16:30:36 -04:00
Jeremy Stretch
68599351aa Merge pull request #4693 from netbox-community/develop
Release v2.8.5
2020-05-26 16:27:36 -04:00
Jeremy Stretch
86755029ef Merge pull request #4642 from netbox-community/develop
Release v2.8.4
2020-05-13 17:31:12 -04:00
Jeremy Stretch
c507ab30e9 Merge pull request #4594 from netbox-community/develop
Release v2.8.3
2020-05-06 23:49:27 -04:00
Jeremy Stretch
7d1614b933 Merge pull request #4589 from netbox-community/develop
Release v2.8.2
2020-05-06 15:14:45 -04:00
Jeremy Stretch
a77d1e502c Merge pull request #4528 from netbox-community/develop
Release v2.8.1
2020-04-23 10:24:08 -04:00
Jeremy Stretch
d79ed76d80 Merge pull request #4479 from netbox-community/develop
Release v2.8.0
2020-04-13 11:29:33 -04:00
Jeremy Stretch
ccf8059452 Merge pull request #4467 from netbox-community/develop
Release v2.7.12
2020-04-08 13:31:49 -04:00
Jeremy Stretch
3d3d1bc623 Merge pull request #4417 from netbox-community/develop
Release v2.7.11
2020-03-27 12:47:28 -04:00
Jeremy Stretch
e66d065b6d Merge pull request #4339 from netbox-community/develop
Release v2.7.10
2020-03-10 13:59:50 -04:00
Jeremy Stretch
c1ef87e009 Merge pull request #4321 from netbox-community/develop
Release v2.7.9
2020-03-06 11:23:42 -05:00
Jeremy Stretch
3c249a40a0 Merge pull request #4275 from netbox-community/develop
Release v2.7.8
2020-02-25 15:09:41 -05:00
Jeremy Stretch
5092641157 Merge pull request #4216 from netbox-community/develop
Release v2.7.7
2020-02-20 14:56:27 -05:00
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
120cbb0159 Merge pull request #4165 from netbox-community/develop
Release v2.7.5
2020-02-13 15:36:50 -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
3143f75a38 Merge pull request #4035 from netbox-community/develop
Release v2.7.3
2020-01-28 16:39:09 -05:00
Jeremy Stretch
e13d4ffe60 Merge pull request #3980 from netbox-community/develop
Release v2.7.2
2020-01-21 15:12:00 -05:00
Jeremy Stretch
295d4f0394 Merge pull request #3946 from netbox-community/develop
Release v2.7.1
2020-01-16 23:46:40 -05:00
Jeremy Stretch
ea91e09a1b Merge pull request #3938 from netbox-community/develop
Release v2.7.0
2020-01-16 13:03:42 -05:00
Jeremy Stretch
946779000f Merge pull request #3908 from netbox-community/develop
Release v2.6.12
2020-01-13 13:25:21 -05:00
Jeremy Stretch
5c07b6dc1d Merge pull request #3837 from netbox-community/develop
Release v2.6.11
2020-01-03 14:00:10 -05:00
Jeremy Stretch
25c3c1b431 Merge pull request #3829 from netbox-community/develop
Fix v2.6.10 release date
2020-01-02 17:05:21 -05:00
Jeremy Stretch
a0ae7a227d Merge pull request #3828 from netbox-community/develop
Release v2.6.10
2020-01-02 17:02:52 -05:00
Jeremy Stretch
50df3acd26 Merge pull request #3774 from netbox-community/develop
Release v2.6.9
2019-12-16 16:32:00 -05:00
Jeremy Stretch
425670f52a Merge pull request #3745 from netbox-community/develop
Release v2.6.8
2019-12-10 10:47:48 -05:00
62 changed files with 3153 additions and 6544 deletions

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@
/netbox/static
/venv/
/*.sh
local_requirements.txt
!upgrade.sh
fabfile.py
gunicorn.py

View File

@@ -24,7 +24,7 @@ Only links which render with non-empty text are included on the page. You can em
For example, if you only want to display a link for active devices, you could set the link text to
```
{% if obj.status == 1 %}View NMS{% endif %}
{% if obj.status == 'active' %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."

View File

@@ -156,9 +156,13 @@ direction = ChoiceVar(choices=CHOICES)
### ObjectVar
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
A NetBox object of a particular type, identified by the associated queryset. Most models will utilize the REST API to retrieve available options: Note that any filtering on the queryset in this case has no effect.
* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/)
* `queryset` - The base [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/) for the model
### MultiObjectVar
Similar to `ObjectVar`, but allows for the selection of multiple objects.
### FileVar
@@ -222,10 +226,7 @@ class NewBranchScript(Script):
)
switch_model = ObjectVar(
description="Access switch model",
queryset = DeviceType.objects.filter(
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
queryset = DeviceType.objects.all()
)
def run(self, data, commit):

View File

@@ -145,3 +145,18 @@ $ curl -v -X DELETE -H "Authorization: Token d2f763479f703d80de0ec15254237bc651f
```
The response to a successful `DELETE` request will have code 204 (No Content); the body of the response will be empty.
## Bulk Object Creation
The REST API supports the creation of multiple objects of the same type using a single `POST` request. For example, to create multiple devices:
```
curl -X POST -H "Authorization: Token <TOKEN>" -H "Content-Type: application/json" -H "Accept: application/json; indent=4" http://localhost:8000/api/dcim/devices/ --data '[
{"name": "device1", "device_type": 24, "device_role": 17, "site": 6},
{"name": "device2", "device_type": 24, "device_role": 17, "site": 6},
{"name": "device3", "device_type": 24, "device_role": 17, "site": 6},
]'
```
Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. A successful response returns an HTTP code 201 and the body of the response will be a list/array of the objects created.

View File

@@ -13,6 +13,14 @@ ADMINS = [
---
## ALLOWED_URL_SCHEMES
Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')`
A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate the entire default list as well (excluding those schemes which are not desirable).
---
## BANNER_TOP
## BANNER_BOTTOM
@@ -374,6 +382,22 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv
---
## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
Default: 22
Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`.
---
## RACK_ELEVATION_DEFAULT_UNIT_WIDTH
Default: 220
Default width (in pixels) of a unit within a rack elevation.
---
## REMOTE_AUTH_ENABLED
Default: `False`

View File

@@ -44,11 +44,7 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
## 6. Add choices to API view
If the new field has static choices, add it to the `FieldChoicesViewSet` for the app.
## 7. Add field to forms
## 6. Add field to forms
Extend any forms to include the new field as appropriate. Common forms include:
@@ -57,19 +53,19 @@ Extend any forms to include the new field as appropriate. Common forms include:
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
## 8. Extend object filter set
## 7. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
## 9. Add column to object table
## 8. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
## 10. Update the UI templates
## 9. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
## 11. Create/extend test cases
## 10. Create/extend test cases
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:

View File

@@ -1,5 +1,49 @@
# NetBox v2.8
## v2.8.7 (2020-07-02)
### Enhancements
* [#4796](https://github.com/netbox-community/netbox/issues/4796) - Introduce configuration parameters for default rack elevation size
* [#4802](https://github.com/netbox-community/netbox/issues/4802) - Allow changing page size when displaying only a single page of results
### Bug Fixes
* [#4695](https://github.com/netbox-community/netbox/issues/4695) - Expose cable termination type choices in OpenAPI spec
* [#4708](https://github.com/netbox-community/netbox/issues/4708) - Relax connection constraints for multi-position rear ports
* [#4766](https://github.com/netbox-community/netbox/issues/4766) - Fix redirect after login when `next` is not specified
* [#4771](https://github.com/netbox-community/netbox/issues/4771) - Fix add/remove tag population when bulk editing objects
* [#4772](https://github.com/netbox-community/netbox/issues/4772) - Fix "brief" format for the secrets REST API endpoint
* [#4774](https://github.com/netbox-community/netbox/issues/4774) - Fix exception when deleting a device with device bays
* [#4775](https://github.com/netbox-community/netbox/issues/4775) - Allow selecting an alternate device type when creating component templates
---
## v2.8.6 (2020-06-15)
### Enhancements
* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
* [#4717](https://github.com/netbox-community/netbox/issues/4717) - Introduce `ALLOWED_URL_SCHEMES` configuration parameter to mitigate dangerous hyperlinks
* [#4744](https://github.com/netbox-community/netbox/issues/4744) - Hide "IP addresses" tab when viewing a container prefix
* [#4755](https://github.com/netbox-community/netbox/issues/4755) - Enable creation of rack reservations directly from navigation menu
* [#4761](https://github.com/netbox-community/netbox/issues/4761) - Enable tag assignment during bulk creation of IP addresses
### Bug Fixes
* [#4674](https://github.com/netbox-community/netbox/issues/4674) - Fix API definition for available prefix and IP address endpoints
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
* [#4710](https://github.com/netbox-community/netbox/issues/4710) - Fix merging of form fields among custom scripts
* [#4725](https://github.com/netbox-community/netbox/issues/4725) - Fix "brief" rendering of various REST API endpoints
* [#4736](https://github.com/netbox-community/netbox/issues/4736) - Add cable trace endpoints for pass-through ports
* [#4737](https://github.com/netbox-community/netbox/issues/4737) - Fix display of role labels in virtual machines table
* [#4743](https://github.com/netbox-community/netbox/issues/4743) - Allow users to create "next available" IPs without needing permission to create prefixes
* [#4756](https://github.com/netbox-community/netbox/issues/4756) - Filter parent group by site when creating rack groups
* [#4760](https://github.com/netbox-community/netbox/issues/4760) - Enable power port template assignment when bulk editing power outlet templates
---
## v2.8.5 (2020-05-26)
**Note:** The minimum required version of PostgreSQL is now 9.6.

View File

@@ -1,443 +1,188 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site
from extras.models import Graph
from utilities.testing import APITestCase
from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
def test_root(self):
url = reverse('circuits-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)
class ProviderTest(APITestCase):
class ProviderTest(APIViewTestCases.APIViewTestCase):
model = Provider
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Provider 4',
'slug': 'provider-4',
},
{
'name': 'Provider 5',
'slug': 'provider-5',
},
{
'name': 'Provider 6',
'slug': 'provider-6',
},
]
def setUp(self):
@classmethod
def setUpTestData(cls):
super().setUp()
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3')
def test_get_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.provider1.name)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
Provider(name='Provider 3', slug='provider-3'),
)
Provider.objects.bulk_create(providers)
def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
ct = ContentType.objects.get(app_label='circuits', model='provider')
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
provider_ct = ContentType.objects.get(app_label='circuits', model='provider')
self.graph1 = Graph.objects.create(
type=provider_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=provider_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=provider_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
)
url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.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?provider=test-provider-1&foo=1')
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
def test_list_providers(self):
url = reverse('circuits-api:provider-list')
response = self.client.get(url, **self.header)
class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
model = CircuitType
brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
create_data = (
{
'name': 'Circuit Type 4',
'slug': 'circuit-type-4',
},
{
'name': 'Circuit Type 5',
'slug': 'circuit-type-5',
},
{
'name': 'Circuit Type 6',
'slug': 'circuit-type-6',
},
)
self.assertEqual(response.data['count'], 3)
@classmethod
def setUpTestData(cls):
def test_list_providers_brief(self):
url = reverse('circuits-api:provider-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['circuit_count', 'id', 'name', 'slug', 'url']
circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)
CircuitType.objects.bulk_create(circuit_types)
def test_create_provider(self):
data = {
'name': 'Test Provider 4',
'slug': 'test-provider-4',
}
class CircuitTest(APIViewTestCases.APIViewTestCase):
model = Circuit
brief_fields = ['cid', 'id', 'url']
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.objects.count(), 4)
provider4 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider4.name, data['name'])
self.assertEqual(provider4.slug, data['slug'])
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
)
Provider.objects.bulk_create(providers)
def test_create_provider_bulk(self):
circuit_types = (
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
)
CircuitType.objects.bulk_create(circuit_types)
data = [
circuits = (
Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]),
Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]),
Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]),
)
Circuit.objects.bulk_create(circuits)
cls.create_data = [
{
'name': 'Test Provider 4',
'slug': 'test-provider-4',
'cid': 'Circuit 4',
'provider': providers[1].pk,
'type': circuit_types[1].pk,
},
{
'name': 'Test Provider 5',
'slug': 'test-provider-5',
'cid': 'Circuit 5',
'provider': providers[1].pk,
'type': circuit_types[1].pk,
},
{
'name': 'Test Provider 6',
'slug': 'test-provider-6',
'cid': 'Circuit 6',
'provider': providers[1].pk,
'type': circuit_types[1].pk,
},
]
url = reverse('circuits-api:provider-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Provider.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'])
class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
model = CircuitTermination
brief_fields = ['circuit', 'id', 'term_side', 'url']
def test_update_provider(self):
@classmethod
def setUpTestData(cls):
SIDE_A = CircuitTerminationSideChoices.SIDE_A
SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
data = {
'name': 'Test Provider X',
'slug': 'test-provider-x',
}
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Provider.objects.count(), 3)
provider1 = Provider.objects.get(pk=response.data['id'])
self.assertEqual(provider1.name, data['name'])
self.assertEqual(provider1.slug, data['slug'])
def test_delete_provider(self):
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Provider.objects.count(), 2)
class CircuitTypeTest(APITestCase):
def setUp(self):
super().setUp()
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3')
def test_get_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.circuittype1.name)
def test_list_circuittypes(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_circuittypes_brief(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['circuit_count', 'id', 'name', 'slug', 'url']
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
def test_create_circuittype(self):
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
data = {
'name': 'Test Circuit Type 4',
'slug': 'test-circuit-type-4',
}
url = reverse('circuits-api:circuittype-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitType.objects.count(), 4)
circuittype4 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype4.name, data['name'])
self.assertEqual(circuittype4.slug, data['slug'])
def test_update_circuittype(self):
data = {
'name': 'Test Circuit Type X',
'slug': 'test-circuit-type-x',
}
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitType.objects.count(), 3)
circuittype1 = CircuitType.objects.get(pk=response.data['id'])
self.assertEqual(circuittype1.name, data['name'])
self.assertEqual(circuittype1.slug, data['slug'])
def test_delete_circuittype(self):
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitType.objects.count(), 2)
class CircuitTest(APITestCase):
def setUp(self):
super().setUp()
self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1)
def test_get_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['cid'], self.circuit1.cid)
def test_list_circuits(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_circuits_brief(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cid', 'id', 'url']
circuits = (
Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
Circuit(cid='Circuit 2', provider=provider, type=circuit_type),
Circuit(cid='Circuit 3', provider=provider, type=circuit_type),
)
Circuit.objects.bulk_create(circuits)
def test_create_circuit(self):
circuit_terminations = (
CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
)
CircuitTermination.objects.bulk_create(circuit_terminations)
data = {
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
}
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 4)
circuit4 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit4.cid, data['cid'])
self.assertEqual(circuit4.provider_id, data['provider'])
self.assertEqual(circuit4.type_id, data['type'])
def test_create_circuit_bulk(self):
data = [
cls.create_data = [
{
'cid': 'TEST0004',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
'circuit': circuits[2].pk,
'term_side': SIDE_A,
'site': sites[1].pk,
'port_speed': 200000,
},
{
'cid': 'TEST0005',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
},
{
'cid': 'TEST0006',
'provider': self.provider1.pk,
'type': self.circuittype1.pk,
'status': CircuitStatusChoices.STATUS_ACTIVE,
'circuit': circuits[2].pk,
'term_side': SIDE_Z,
'site': sites[1].pk,
'port_speed': 200000,
},
]
url = reverse('circuits-api:circuit-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Circuit.objects.count(), 6)
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
def test_update_circuit(self):
data = {
'cid': 'TEST000X',
'provider': self.provider2.pk,
'type': self.circuittype2.pk,
}
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Circuit.objects.count(), 3)
circuit1 = Circuit.objects.get(pk=response.data['id'])
self.assertEqual(circuit1.cid, data['cid'])
self.assertEqual(circuit1.provider_id, data['provider'])
self.assertEqual(circuit1.type_id, data['type'])
def test_delete_circuit(self):
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Circuit.objects.count(), 2)
class CircuitTerminationTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
self.circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit1,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
self.circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit1,
term_side=CircuitTerminationSideChoices.SIDE_Z,
site=self.site2,
port_speed=1000000
)
self.circuittermination3 = CircuitTermination.objects.create(
circuit=self.circuit2,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
self.circuittermination4 = CircuitTermination.objects.create(
circuit=self.circuit2,
term_side=CircuitTerminationSideChoices.SIDE_Z,
site=self.site2,
port_speed=1000000
)
def test_get_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['id'], self.circuittermination1.pk)
def test_list_circuitterminations(self):
url = reverse('circuits-api:circuittermination-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 4)
def test_create_circuittermination(self):
data = {
'circuit': self.circuit3.pk,
'term_side': CircuitTerminationSideChoices.SIDE_A,
'site': self.site1.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination4.circuit_id, data['circuit'])
self.assertEqual(circuittermination4.term_side, data['term_side'])
self.assertEqual(circuittermination4.site_id, data['site'])
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
def test_update_circuittermination(self):
circuittermination5 = CircuitTermination.objects.create(
circuit=self.circuit3,
term_side=CircuitTerminationSideChoices.SIDE_A,
site=self.site1,
port_speed=1000000
)
data = {
'circuit': self.circuit3.pk,
'term_side': CircuitTerminationSideChoices.SIDE_Z,
'site': self.site2.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination1.term_side, data['term_side'])
self.assertEqual(circuittermination1.site_id, data['site'])
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
def test_delete_circuittermination(self):
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitTermination.objects.count(), 3)

View File

@@ -1,32 +1,35 @@
from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from dcim import models
from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [
'NestedCableSerializer',
'NestedConsolePortSerializer',
'NestedConsolePortTemplateSerializer',
'NestedConsoleServerPortSerializer',
'NestedConsoleServerPortTemplateSerializer',
'NestedDeviceBaySerializer',
'NestedDeviceBayTemplateSerializer',
'NestedDeviceRoleSerializer',
'NestedDeviceSerializer',
'NestedDeviceTypeSerializer',
'NestedFrontPortSerializer',
'NestedFrontPortTemplateSerializer',
'NestedInterfaceSerializer',
'NestedInterfaceTemplateSerializer',
'NestedInventoryItemSerializer',
'NestedManufacturerSerializer',
'NestedPlatformSerializer',
'NestedPowerFeedSerializer',
'NestedPowerOutletSerializer',
'NestedPowerOutletTemplateSerializer',
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedPowerPortTemplateSerializer',
'NestedRackGroupSerializer',
'NestedRackReservationSerializer',
'NestedRackRoleSerializer',
'NestedRackSerializer',
'NestedRearPortSerializer',
@@ -46,7 +49,7 @@ class NestedRegionSerializer(WritableNestedSerializer):
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = Region
model = models.Region
fields = ['id', 'url', 'name', 'slug', 'site_count']
@@ -54,7 +57,7 @@ class NestedSiteSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
class Meta:
model = Site
model = models.Site
fields = ['id', 'url', 'name', 'slug']
@@ -67,7 +70,7 @@ class NestedRackGroupSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackGroup
model = models.RackGroup
fields = ['id', 'url', 'name', 'slug', 'rack_count']
@@ -76,7 +79,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
model = models.RackRole
fields = ['id', 'url', 'name', 'slug', 'rack_count']
@@ -85,10 +88,22 @@ class NestedRackSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
model = models.Rack
fields = ['id', 'url', 'name', 'display_name', 'device_count']
class NestedRackReservationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
user = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.RackReservation
fields = ['id', 'url', 'user', 'units']
def get_user(self, obj):
return obj.user.username
#
# Device types
#
@@ -98,7 +113,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
devicetype_count = serializers.IntegerField(read_only=True)
class Meta:
model = Manufacturer
model = models.Manufacturer
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
@@ -108,15 +123,47 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
model = models.DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
class Meta:
model = models.ConsolePortTemplate
fields = ['id', 'url', 'name']
class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
class Meta:
model = models.ConsoleServerPortTemplate
fields = ['id', 'url', 'name']
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
class Meta:
model = PowerPortTemplate
model = models.PowerPortTemplate
fields = ['id', 'url', 'name']
class NestedPowerOutletTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
class Meta:
model = models.PowerOutletTemplate
fields = ['id', 'url', 'name']
class NestedInterfaceTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
class Meta:
model = models.InterfaceTemplate
fields = ['id', 'url', 'name']
@@ -124,7 +171,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
class Meta:
model = RearPortTemplate
model = models.RearPortTemplate
fields = ['id', 'url', 'name']
@@ -132,7 +179,15 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
class Meta:
model = FrontPortTemplate
model = models.FrontPortTemplate
fields = ['id', 'url', 'name']
class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
class Meta:
model = models.DeviceBayTemplate
fields = ['id', 'url', 'name']
@@ -146,7 +201,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
model = models.DeviceRole
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
@@ -156,7 +211,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = Platform
model = models.Platform
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
@@ -164,7 +219,7 @@ class NestedDeviceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
class Meta:
model = Device
model = models.Device
fields = ['id', 'url', 'name', 'display_name']
@@ -174,7 +229,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = ConsoleServerPort
model = models.ConsoleServerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@@ -184,7 +239,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = ConsolePort
model = models.ConsolePort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@@ -194,7 +249,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = PowerOutlet
model = models.PowerOutlet
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@@ -204,7 +259,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = PowerPort
model = models.PowerPort
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@@ -214,7 +269,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer):
connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
class Meta:
model = Interface
model = models.Interface
fields = ['id', 'url', 'device', 'name', 'cable', 'connection_status']
@@ -223,7 +278,7 @@ class NestedRearPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
class Meta:
model = RearPort
model = models.RearPort
fields = ['id', 'url', 'device', 'name', 'cable']
@@ -232,7 +287,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
class Meta:
model = FrontPort
model = models.FrontPort
fields = ['id', 'url', 'device', 'name', 'cable']
@@ -241,7 +296,16 @@ class NestedDeviceBaySerializer(WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = DeviceBay
model = models.DeviceBay
fields = ['id', 'url', 'device', 'name']
class NestedInventoryItemSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = models.InventoryItem
fields = ['id', 'url', 'device', 'name']
@@ -253,7 +317,7 @@ class NestedCableSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
class Meta:
model = Cable
model = models.Cable
fields = ['id', 'url', 'label']
@@ -267,7 +331,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
model = models.VirtualChassis
fields = ['id', 'url', 'master', 'member_count']
@@ -280,7 +344,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer):
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = PowerPanel
model = models.PowerPanel
fields = ['id', 'url', 'name', 'powerfeed_count']
@@ -288,5 +352,5 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
class Meta:
model = PowerFeed
model = models.PowerFeed
fields = ['id', 'url', 'name']

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@@ -185,10 +186,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
default=RackElevationDetailRenderChoices.RENDER_JSON
)
unit_width = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT
default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
)
unit_height = serializers.IntegerField(
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
)
legend_width = serializers.IntegerField(
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT

View File

@@ -29,6 +29,7 @@ from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
)
from utilities.utils import get_subquery
from utilities.metadata import ContentTypeMetadata
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@@ -502,13 +503,13 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
return Response(serializer.data)
class FrontPortViewSet(ModelViewSet):
class FrontPortViewSet(CableTraceMixin, ModelViewSet):
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilterSet
class RearPortViewSet(ModelViewSet):
class RearPortViewSet(CableTraceMixin, ModelViewSet):
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilterSet
@@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
#
class CableViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)

View File

@@ -260,6 +260,7 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p'
TYPE_NEMA_520P = 'nema-5-20p'
TYPE_NEMA_530P = 'nema-5-30p'
@@ -268,16 +269,27 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_620P = 'nema-6-20p'
TYPE_NEMA_630P = 'nema-6-30p'
TYPE_NEMA_650P = 'nema-6-50p'
TYPE_NEMA_1030P = 'nema-10-30p'
TYPE_NEMA_1050P = 'nema-10-50p'
TYPE_NEMA_1420P = 'nema-14-20p'
TYPE_NEMA_1430P = 'nema-14-30p'
TYPE_NEMA_1450P = 'nema-14-50p'
TYPE_NEMA_1460P = 'nema-14-60p'
# NEMA locking
TYPE_NEMA_L115P = 'nema-l1-15p'
TYPE_NEMA_L515P = 'nema-l5-15p'
TYPE_NEMA_L520P = 'nema-l5-20p'
TYPE_NEMA_L530P = 'nema-l5-30p'
TYPE_NEMA_L615P = 'nema-l5-50p'
TYPE_NEMA_L550P = 'nema-l5-50p'
TYPE_NEMA_L615P = 'nema-l6-15p'
TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p'
TYPE_NEMA_L650P = 'nema-l6-50p'
TYPE_NEMA_L1030P = 'nema-l10-30p'
TYPE_NEMA_L1420P = 'nema-l14-20p'
TYPE_NEMA_L1430P = 'nema-l14-30p'
TYPE_NEMA_L1450P = 'nema-l14-50p'
TYPE_NEMA_L1460P = 'nema-l14-60p'
TYPE_NEMA_L2120P = 'nema-l21-20p'
TYPE_NEMA_L2130P = 'nema-l21-30p'
# California style
@@ -324,6 +336,7 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'),
(TYPE_NEMA_520P, 'NEMA 5-20P'),
(TYPE_NEMA_530P, 'NEMA 5-30P'),
@@ -332,17 +345,28 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_620P, 'NEMA 6-20P'),
(TYPE_NEMA_630P, 'NEMA 6-30P'),
(TYPE_NEMA_650P, 'NEMA 6-50P'),
(TYPE_NEMA_1030P, 'NEMA 10-30P'),
(TYPE_NEMA_1050P, 'NEMA 10-50P'),
(TYPE_NEMA_1420P, 'NEMA 14-20P'),
(TYPE_NEMA_1430P, 'NEMA 14-30P'),
(TYPE_NEMA_1450P, 'NEMA 14-50P'),
(TYPE_NEMA_1460P, 'NEMA 14-60P'),
)),
('NEMA (Locking)', (
(TYPE_NEMA_L115P, 'NEMA L1-15P'),
(TYPE_NEMA_L515P, 'NEMA L5-15P'),
(TYPE_NEMA_L520P, 'NEMA L5-20P'),
(TYPE_NEMA_L530P, 'NEMA L5-30P'),
(TYPE_NEMA_L550P, 'NEMA L5-50P'),
(TYPE_NEMA_L615P, 'NEMA L6-15P'),
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
(TYPE_NEMA_L650P, 'NEMA L6-50P'),
(TYPE_NEMA_L1030P, 'NEMA L10-30P'),
(TYPE_NEMA_L1420P, 'NEMA L14-20P'),
(TYPE_NEMA_L1430P, 'NEMA L14-30P'),
(TYPE_NEMA_L1450P, 'NEMA L14-50P'),
(TYPE_NEMA_L1460P, 'NEMA L14-60P'),
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
)),
@@ -397,6 +421,7 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r'
TYPE_NEMA_520R = 'nema-5-20r'
TYPE_NEMA_530R = 'nema-5-30r'
@@ -405,16 +430,27 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_620R = 'nema-6-20r'
TYPE_NEMA_630R = 'nema-6-30r'
TYPE_NEMA_650R = 'nema-6-50r'
TYPE_NEMA_1030R = 'nema-10-30r'
TYPE_NEMA_1050R = 'nema-10-50r'
TYPE_NEMA_1420R = 'nema-14-20r'
TYPE_NEMA_1430R = 'nema-14-30r'
TYPE_NEMA_1450R = 'nema-14-50r'
TYPE_NEMA_1460R = 'nema-14-60r'
# NEMA locking
TYPE_NEMA_L115R = 'nema-l1-15r'
TYPE_NEMA_L515R = 'nema-l5-15r'
TYPE_NEMA_L520R = 'nema-l5-20r'
TYPE_NEMA_L530R = 'nema-l5-30r'
TYPE_NEMA_L615R = 'nema-l5-50r'
TYPE_NEMA_L550R = 'nema-l5-50r'
TYPE_NEMA_L615R = 'nema-l6-15r'
TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r'
TYPE_NEMA_L650R = 'nema-l6-50r'
TYPE_NEMA_L1030R = 'nema-l10-30r'
TYPE_NEMA_L1420R = 'nema-l14-20r'
TYPE_NEMA_L1430R = 'nema-l14-30r'
TYPE_NEMA_L1450R = 'nema-l14-50r'
TYPE_NEMA_L1460R = 'nema-l14-60r'
TYPE_NEMA_L2120R = 'nema-l21-20r'
TYPE_NEMA_L2130R = 'nema-l21-30r'
# California style
@@ -462,6 +498,7 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)),
('NEMA (Non-locking)', (
(TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'),
(TYPE_NEMA_520R, 'NEMA 5-20R'),
(TYPE_NEMA_530R, 'NEMA 5-30R'),
@@ -470,17 +507,28 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_620R, 'NEMA 6-20R'),
(TYPE_NEMA_630R, 'NEMA 6-30R'),
(TYPE_NEMA_650R, 'NEMA 6-50R'),
(TYPE_NEMA_1030R, 'NEMA 10-30R'),
(TYPE_NEMA_1050R, 'NEMA 10-50R'),
(TYPE_NEMA_1420R, 'NEMA 14-20R'),
(TYPE_NEMA_1430R, 'NEMA 14-30R'),
(TYPE_NEMA_1450R, 'NEMA 14-50R'),
(TYPE_NEMA_1460R, 'NEMA 14-60R'),
)),
('NEMA (Locking)', (
(TYPE_NEMA_L115R, 'NEMA L1-15R'),
(TYPE_NEMA_L515R, 'NEMA L5-15R'),
(TYPE_NEMA_L520R, 'NEMA L5-20R'),
(TYPE_NEMA_L530R, 'NEMA L5-30R'),
(TYPE_NEMA_L550R, 'NEMA L5-50R'),
(TYPE_NEMA_L615R, 'NEMA L6-15R'),
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
(TYPE_NEMA_L650R, 'NEMA L6-50R'),
(TYPE_NEMA_L1030R, 'NEMA L10-30R'),
(TYPE_NEMA_L1420R, 'NEMA L14-20R'),
(TYPE_NEMA_L1430R, 'NEMA L14-30R'),
(TYPE_NEMA_L1450R, 'NEMA L14-50R'),
(TYPE_NEMA_L1460R, 'NEMA L14-60R'),
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
)),

View File

@@ -11,8 +11,6 @@ RACK_U_HEIGHT_DEFAULT = 42
RACK_ELEVATION_BORDER_WIDTH = 2
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
#

View File

@@ -21,10 +21,10 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField,
CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
JSONField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
@@ -363,7 +363,12 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
class RackGroupForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all()
queryset=Site.objects.all(),
widget=APISelect(
filter_for={
'parent': 'site_id',
}
)
)
parent = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
@@ -729,21 +734,32 @@ class RackElevationFilterForm(RackFilterForm):
#
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
rack = forms.ModelChoiceField(
queryset=Rack.objects.all(),
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=forms.HiddenInput()
)
# TODO: Change this to an API-backed form field. We can't do this currently because we want to retain
# the multi-line <select> widget for easy selection of multiple rack units.
units = SimpleArrayField(
base_field=forms.IntegerField(),
widget=ArrayFieldSelectMultiple(
attrs={
'size': 10,
widget=APISelect(
filter_for={
'rack_group': 'site_id',
'rack': 'site_id',
}
)
)
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
widget=APISelect(
filter_for={
'rack': 'group_id'
}
)
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all()
)
units = NumericArrayField(
base_field=forms.IntegerField(),
help_text="Comma-separated list of numeric unit IDs. A range may be specified using a hyphen."
)
user = forms.ModelChoiceField(
queryset=User.objects.order_by(
'username'
@@ -757,23 +773,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Populate rack unit choices
if hasattr(self.instance, 'rack'):
self.fields['units'].widget.choices = self._get_unit_choices()
def _get_unit_choices(self):
rack = self.instance.rack
reserved_units = []
for resv in rack.reservations.exclude(pk=self.instance.pk):
for u in resv.units:
reserved_units.append(u)
unit_choices = [(u, {'label': str(u), 'disabled': u in reserved_units}) for u in rack.units]
return unit_choices
class RackReservationCSVForm(CSVModelForm):
site = CSVModelChoiceField(
@@ -1027,6 +1026,30 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm):
# Device component templates
#
class ComponentTemplateCreateForm(BootstrapMixin, forms.Form):
"""
Base form for the creation of device component templates.
"""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
widget=APISelect(
filter_for={
'device_type': 'manufacturer_id'
}
)
)
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(),
widget=APISelect(
display_field='model'
)
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
@@ -1039,13 +1062,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
@@ -1079,13 +1096,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
@@ -1119,13 +1130,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False
@@ -1189,13 +1194,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
)
class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False
@@ -1227,11 +1226,21 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=PowerOutletTemplate.objects.all(),
widget=forms.MultipleHiddenInput()
)
device_type = forms.ModelChoiceField(
queryset=DeviceType.objects.all(),
required=False,
disabled=True,
widget=forms.HiddenInput()
)
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
widget=StaticSelect2()
)
power_port = forms.ModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False
)
feed_leg = forms.ChoiceField(
choices=add_blank_choice(PowerOutletFeedLegChoices),
required=False,
@@ -1239,7 +1248,18 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
)
class Meta:
nullable_fields = ('type', 'feed_leg')
nullable_fields = ('type', 'power_port', 'feed_leg')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit power_port queryset to PowerPortTemplates which belong to the parent DeviceType
if 'device_type' in self.initial:
device_type = DeviceType.objects.filter(pk=self.initial['device_type']).first()
self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(device_type=device_type)
else:
self.fields['power_port'].choices = ()
self.fields['power_port'].widget.attrs['disabled'] = True
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
@@ -1255,13 +1275,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
}
class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect2()
@@ -1315,13 +1329,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
)
class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect2()
@@ -1406,13 +1414,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
}
class RearPortTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect2(),
@@ -1452,13 +1454,8 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
pass
# TODO: DeviceBayTemplate has no fields suitable for bulk-editing yet
@@ -2188,9 +2185,21 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
#
# Bulk device component creation
# Device components
#
class ComponentCreateForm(BootstrapMixin, forms.Form):
"""
Base form for the creation of device components.
"""
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
name_pattern = ExpandableNameField(
label='Name'
)
class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
@@ -2236,13 +2245,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
}
class ConsolePortCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsolePortCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
@@ -2322,13 +2325,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
}
class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class ConsoleServerPortCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
@@ -2422,13 +2419,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
}
class PowerPortCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class PowerPortCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False,
@@ -2531,13 +2522,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
)
class PowerOutletCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class PowerOutletCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
@@ -2756,13 +2741,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect2(),
@@ -3039,13 +3018,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class FrontPortCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect2(),
@@ -3219,13 +3192,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
}
class RearPortCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class RearPortCreateForm(ComponentCreateForm):
type = forms.ChoiceField(
choices=PortTypeChoices,
widget=StaticSelect2(),
@@ -3321,13 +3288,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
}
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
device = DynamicModelChoiceField(
queryset=Device.objects.prefetch_related('device_type__manufacturer')
)
name_pattern = ExpandableNameField(
label='Name'
)
class DeviceBayCreateForm(ComponentCreateForm):
tags = TagField(
required=False
)

View File

@@ -731,8 +731,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
def get_elevation_svg(
self,
face=DeviceFaceChoices.FACE_FRONT,
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
include_images=True,
base_url=None
@@ -2115,9 +2115,9 @@ class Cable(ChangeLoggedModel):
"""
instance = super().from_db(db, field_names, values)
instance._orig_termination_a_type = instance.termination_a_type
instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
instance._orig_termination_b_type = instance.termination_b_type
instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
@@ -2129,6 +2129,7 @@ class Cable(ChangeLoggedModel):
return reverse('dcim:cable', args=[self.pk])
def clean(self):
from circuits.models import CircuitTermination
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
@@ -2154,14 +2155,14 @@ class Cable(ChangeLoggedModel):
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
self.termination_a_type != self._orig_termination_a_type or
self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
self.termination_b_type != self._orig_termination_b_type or
self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
@@ -2191,19 +2192,21 @@ class Cable(ChangeLoggedModel):
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
# Check that a RearPort with multiple positions isn't connected to an endpoint
# or a RearPort with a different number of positions.
for term_a, term_b in [
(self.termination_a, self.termination_b),
(self.termination_b, self.termination_a)
]:
if isinstance(term_a, RearPort) and term_a.positions > 1:
if not isinstance(term_b, RearPort):
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
raise ValidationError(
"Rear ports with multiple positions may only be connected to other rear ports"
"Rear ports with multiple positions may only be connected to other pass-through ports"
)
elif term_a.positions != term_b.positions:
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
f"{term_b} of {term_b.device} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
)

View File

@@ -44,6 +44,9 @@ class ComponentModel(models.Model):
class Meta:
abstract = True
def __str__(self):
return getattr(self, 'name')
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
@@ -86,16 +89,16 @@ class CableTermination(models.Model):
object_id_field='termination_b_id'
)
is_path_endpoint = True
class Meta:
abstract = True
def trace(self):
"""
Return two items: the traceable portion of a cable path, and the termination points where it splits (if any).
This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where
the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow.
Return three items: the traceable portion of a cable path, the termination points where it splits (if any), and
the remaining positions on the position stack (if any). Splits occur when the trace is initiated from a midpoint
along a path which traverses a RearPort. In cases where the originating endpoint is unknown, it is not possible
to know which corresponding FrontPort to follow. Remaining positions occur when tracing a path that traverses
a FrontPort without traversing a RearPort again.
The path is a list representing a complete cable path, with each individual segment represented as a
three-tuple:
@@ -115,26 +118,35 @@ class CableTermination(models.Model):
# Map a front port to its corresponding rear port
if isinstance(termination, FrontPort):
position_stack.append(termination.rear_port_position)
# Retrieve the corresponding RearPort from database to ensure we have an up-to-date instance
peer_port = RearPort.objects.get(pk=termination.rear_port.pk)
# Don't use the stack for RearPorts with a single position. Only remember the position at
# many-to-one points so we can select the correct FrontPort when we reach the corresponding
# one-to-many point.
if peer_port.positions > 1:
position_stack.append(termination)
return peer_port
# Map a rear port/position to its corresponding front port
elif isinstance(termination, RearPort):
if termination.positions > 1:
# Can't map to a FrontPort without a position if there are multiple options
if not position_stack:
raise CableTraceSplit(termination)
# Can't map to a FrontPort without a position if there are multiple options
if termination.positions > 1 and not position_stack:
raise CableTraceSplit(termination)
front_port = position_stack.pop()
position = front_port.rear_port_position
# We can assume position 1 if the RearPort has only one position
position = position_stack.pop() if position_stack else 1
# Validate the position
if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position
))
# Validate the position
if position not in range(1, termination.positions + 1):
raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position
))
else:
# Don't use the stack for RearPorts with a single position. The only possible position is 1.
position = 1
try:
peer_port = FrontPort.objects.get(
@@ -165,12 +177,12 @@ class CableTermination(models.Model):
if not endpoint.cable:
path.append((endpoint, None, None))
logger.debug("No cable connected")
return path, None
return path, None, position_stack
# Check for loops
if endpoint.cable in [segment[1] for segment in path]:
logger.debug("Loop detected!")
return path, None
return path, None, position_stack
# Record the current segment in the path
far_end = endpoint.get_cable_peer()
@@ -183,10 +195,10 @@ class CableTermination(models.Model):
try:
endpoint = get_peer_port(far_end)
except CableTraceSplit as e:
return path, e.termination.frontports.all()
return path, e.termination.frontports.all(), position_stack
if endpoint is None:
return path, None
return path, None, position_stack
def get_cable_peer(self):
if self.cable is None:
@@ -203,7 +215,7 @@ class CableTermination(models.Model):
endpoints = []
# Get the far end of the last path segment
path, split_ends = self.trace()
path, split_ends, position_stack = self.trace()
endpoint = path[-1][2]
if split_ends is not None:
for termination in split_ends:
@@ -261,9 +273,6 @@ class ConsolePort(CableTermination, ComponentModel):
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
@@ -316,9 +325,6 @@ class ConsoleServerPort(CableTermination, ComponentModel):
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
@@ -397,9 +403,6 @@ class PowerPort(CableTermination, ComponentModel):
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
@@ -547,9 +550,6 @@ class PowerOutlet(CableTermination, ComponentModel):
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return self.device.get_absolute_url()
@@ -685,9 +685,6 @@ class Interface(CableTermination, ComponentModel):
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:interface', kwargs={'pk': self.pk})
@@ -884,7 +881,6 @@ class FrontPort(CableTermination, ComponentModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
is_path_endpoint = False
class Meta:
ordering = ('device', '_name')
@@ -893,9 +889,6 @@ class FrontPort(CableTermination, ComponentModel):
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
def to_csv(self):
return (
self.device.identifier,
@@ -952,15 +945,11 @@ class RearPort(CableTermination, ComponentModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
is_path_endpoint = False
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return self.name
def to_csv(self):
return (
self.device.identifier,
@@ -1009,9 +998,6 @@ class DeviceBay(ComponentModel):
ordering = ('device', '_name')
unique_together = ('device', 'name')
def __str__(self):
return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self):
return self.device.get_absolute_url()

View File

@@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from .choices import CableStatusChoices
from .models import Cable, Device, VirtualChassis
from .models import Cable, CableTermination, Device, FrontPort, RearPort, VirtualChassis
@receiver(post_save, sender=VirtualChassis)
@@ -52,7 +52,7 @@ def update_connected_endpoints(instance, **kwargs):
# Update any endpoints for this Cable.
endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints()
for endpoint in endpoints:
path, split_ends = endpoint.trace()
path, split_ends, position_stack = endpoint.trace()
# Determine overall path status (connected or planned)
path_status = True
for segment in path:
@@ -61,9 +61,11 @@ def update_connected_endpoints(instance, **kwargs):
break
endpoint_a = path[0][0]
endpoint_b = path[-1][2]
endpoint_b = path[-1][2] if not split_ends and not position_stack else None
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
# Patch panel ports are not connected endpoints, all other cable terminations are
if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status

View File

@@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@@ -72,15 +72,6 @@ RACKROLE_ACTIONS = """
{% endif %}
"""
RACK_ROLE = """
{% if record.role %}
{% load helpers %}
<label class="label" style="color: {{ record.role.color|fgcolor }}; background-color: #{{ record.role.color }}">{{ value }}</label>
{% else %}
&mdash;
{% endif %}
"""
RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
"""
@@ -137,11 +128,6 @@ PLATFORM_ACTIONS = """
{% endif %}
"""
DEVICE_ROLE = """
{% load helpers %}
<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@@ -325,9 +311,7 @@ class RackTable(BaseTable):
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
role = tables.TemplateColumn(
template_code=RACK_ROLE
)
role = ColoredLabelColumn()
u_height = tables.TemplateColumn(
template_code="{{ record.u_height }}U",
verbose_name='Height'
@@ -806,8 +790,7 @@ class DeviceTable(BaseTable):
viewname='dcim:rack',
args=[Accessor('rack.pk')]
)
device_role = tables.TemplateColumn(
template_code=DEVICE_ROLE,
device_role = ColoredLabelColumn(
verbose_name='Role'
)
device_type = tables.LinkColumn(

File diff suppressed because it is too large Load Diff

View File

@@ -363,6 +363,7 @@ class CableTestCase(TestCase):
)
self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
self.interface3 = Interface.objects.create(device=self.device2, name='eth1')
self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2)
self.cable.save()
@@ -370,10 +371,27 @@ class CableTestCase(TestCase):
self.patch_pannel = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='TestPatchPannel', site=site
)
self.rear_port = RearPort.objects.create(device=self.patch_pannel, name='R1', type=1000)
self.front_port = FrontPort.objects.create(
device=self.patch_pannel, name='F1', type=1000, rear_port=self.rear_port
self.rear_port1 = RearPort.objects.create(device=self.patch_pannel, name='RP1', type='8p8c')
self.front_port1 = FrontPort.objects.create(
device=self.patch_pannel, name='FP1', type='8p8c', rear_port=self.rear_port1, rear_port_position=1
)
self.rear_port2 = RearPort.objects.create(device=self.patch_pannel, name='RP2', type='8p8c', positions=2)
self.front_port2 = FrontPort.objects.create(
device=self.patch_pannel, name='FP2', type='8p8c', rear_port=self.rear_port2, rear_port_position=1
)
self.rear_port3 = RearPort.objects.create(device=self.patch_pannel, name='RP3', type='8p8c', positions=3)
self.front_port3 = FrontPort.objects.create(
device=self.patch_pannel, name='FP3', type='8p8c', rear_port=self.rear_port3, rear_port_position=1
)
self.rear_port4 = RearPort.objects.create(device=self.patch_pannel, name='RP4', type='8p8c', positions=3)
self.front_port4 = FrontPort.objects.create(
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1
)
self.provider = Provider.objects.create(name='Provider 1', slug='provider-1')
self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1')
self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A', port_speed=1000)
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z', port_speed=1000)
def test_cable_creation(self):
"""
@@ -405,7 +423,7 @@ class CableTestCase(TestCase):
cable = Cable.objects.filter(pk=self.cable.pk).first()
self.assertIsNone(cable)
def test_cable_validates_compatibale_types(self):
def test_cable_validates_compatible_types(self):
"""
The clean method should have a check to ensure only compatible port types can be connected by a cable
"""
@@ -426,7 +444,7 @@ class CableTestCase(TestCase):
"""
A cable cannot connect a front port to its corresponding rear port
"""
cable = Cable(termination_a=self.front_port, termination_b=self.rear_port)
cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1)
with self.assertRaises(ValidationError):
cable.clean()
@@ -439,7 +457,94 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_terminate_to_a_virtual_inteface(self):
def test_connection_via_single_position_rearport(self):
"""
A RearPort with one position can be connected to anything.
[CableTermination X]---[RP(pos=1) FP]---[CableTermination Y]
is allowed anywhere
[CableTermination X]---[CableTermination Y]
is allowed.
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions. RearPorts with a single position on the other hand may be connected
to such CableTerminations. Check that this is indeed allowed.
"""
# Connecting a single-position RearPort to a multi-position RearPort is ok
Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean()
# Connecting a single-position RearPort to an Interface is ok
Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean()
# Connecting a single-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean()
def test_connection_via_multi_position_rearport(self):
"""
A RearPort with multiple positions may not be directly connected to a path endpoint or another RearPort
with a different number of positions.
The following scenario's are allowed (with x>1):
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=x)
| |
~----------+ +---------~
~----------+ +---------~
| |
RP2(pos=x)|---|RP(pos=1)
| |
~----------+ +---------~
~----------+ +------------------~
| |
RP2(pos=x)|---|CircuitTermination
| |
~----------+ +------------------~
These scenarios are NOT allowed (with x>1):
~----------+ +----------~
| |
RP2(pos=x)|---|RP(pos!=x)
| |
~----------+ +----------~
~----------+ +----------~
| |
RP2(pos=x)|---|Interface
| |
~----------+ +----------~
These scenarios are tested in this order below.
"""
# Connecting a multi-position RearPort to another RearPort with the same number of positions is ok
Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean()
# Connecting a multi-position RearPort to a single-position RearPort is ok
Cable(termination_a=self.rear_port2, termination_b=self.rear_port1).full_clean()
# Connecting a multi-position RearPort to a CircuitTermination is ok
Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a 2-position RearPort to a 3-position RearPort should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean()
with self.assertRaises(
ValidationError,
msg='Connecting a multi-position RearPort to an Interface should fail'
):
Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean()
def test_cable_cannot_terminate_to_a_virtual_interface(self):
"""
A cable cannot terminate to a virtual interface
"""
@@ -448,7 +553,7 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_cannot_terminate_to_a_wireless_inteface(self):
def test_cable_cannot_terminate_to_a_wireless_interface(self):
"""
A cable cannot terminate to a wireless interface
"""
@@ -501,9 +606,13 @@ class CablePathTestCase(TestCase):
Device(device_type=devicetype, device_role=devicerole, name='Panel 2', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 3', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 4', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 5', site=site),
Device(device_type=devicetype, device_role=devicerole, name='Panel 6', site=site),
)
Device.objects.bulk_create(patch_panels)
for patch_panel in patch_panels:
# Create patch panels with 4 positions
for patch_panel in patch_panels[:4]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=4, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.bulk_create((
FrontPort(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C),
@@ -512,6 +621,11 @@ class CablePathTestCase(TestCase):
FrontPort(device=patch_panel, name='Front Port 4', rear_port=rearport, rear_port_position=4, type=PortTypeChoices.TYPE_8P8C),
))
# Create 1-on-1 patch panels
for patch_panel in patch_panels[4:]:
rearport = RearPort.objects.create(device=patch_panel, name='Rear Port 1', positions=1, type=PortTypeChoices.TYPE_8P8C)
FrontPort.objects.create(device=patch_panel, name='Front Port 1', rear_port=rearport, rear_port_position=1, type=PortTypeChoices.TYPE_8P8C)
def test_direct_connection(self):
"""
Test a direct connection between two interfaces.
@@ -524,6 +638,7 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable.full_clean()
cable.save()
# Retrieve endpoints
@@ -551,22 +666,25 @@ class CablePathTestCase(TestCase):
def test_connection_via_single_rear_port(self):
"""
Test a connection which passes through a single front/rear port pair.
Test a connection which passes through a rear port with exactly one front port.
1 2
[Device 1] ----- [Panel 1] ----- [Device 2]
[Device 1] ----- [Panel 5] ----- [Device 2]
Iface1 FP1 RP1 Iface1
"""
# Create cables
# Create cables (FP first, RP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
self.assertEqual(cable2.termination_a.positions, 1) # Sanity check
cable2.full_clean()
cable2.save()
# Retrieve endpoints
@@ -592,6 +710,97 @@ class CablePathTestCase(TestCase):
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
def test_connections_via_nested_single_position_rearport(self):
"""
Test a connection which passes through a single front/rear port pair between two multi-position rear ports.
Test two connections via patched rear ports:
Device 1 <---> Device 2
Device 3 <---> Device 4
1 2
[Device 1] -----------+ +----------- [Device 2]
Iface1 | | Iface1
FP1 | 3 4 | FP1
[Panel 1] ----- [Panel 5] ----- [Panel 2]
FP2 | RP1 RP1 FP1 RP1 | FP2
Iface1 | | Iface1
[Device 3] -----------+ +----------- [Device 4]
5 6
"""
# Create cables (Panel 5 RP first, FP second)
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_b=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable6.full_clean()
cable6.save()
# Retrieve endpoints
endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1')
endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1')
endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1')
endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1')
# Validate connections
self.assertEqual(endpoint_a.connected_endpoint, endpoint_b)
self.assertEqual(endpoint_b.connected_endpoint, endpoint_a)
self.assertEqual(endpoint_c.connected_endpoint, endpoint_d)
self.assertEqual(endpoint_d.connected_endpoint, endpoint_c)
self.assertTrue(endpoint_a.connection_status)
self.assertTrue(endpoint_b.connection_status)
self.assertTrue(endpoint_c.connection_status)
self.assertTrue(endpoint_d.connection_status)
# Delete cable 3
cable3.delete()
# Refresh endpoints
endpoint_a.refresh_from_db()
endpoint_b.refresh_from_db()
endpoint_c.refresh_from_db()
endpoint_d.refresh_from_db()
# Check that connections have been nullified
self.assertIsNone(endpoint_a.connected_endpoint)
self.assertIsNone(endpoint_b.connected_endpoint)
self.assertIsNone(endpoint_c.connected_endpoint)
self.assertIsNone(endpoint_d.connected_endpoint)
self.assertIsNone(endpoint_a.connection_status)
self.assertIsNone(endpoint_b.connection_status)
self.assertIsNone(endpoint_c.connection_status)
self.assertIsNone(endpoint_d.connection_status)
def test_connections_via_patch(self):
"""
Test two connections via patched rear ports:
@@ -613,28 +822,33 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2')
)
cable5.full_clean()
cable5.save()
# Retrieve endpoints
@@ -693,43 +907,51 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'),
termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2')
)
cable7.full_clean()
cable7.save()
cable8 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable8.full_clean()
cable8.save()
# Retrieve endpoints
@@ -789,38 +1011,45 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1')
)
cable4.full_clean()
cable4.save()
cable5 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'),
termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1')
)
cable5.full_clean()
cable5.save()
cable6 = Cable(
termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2')
)
cable6.full_clean()
cable6.save()
cable7 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'),
termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1')
)
cable7.full_clean()
cable7.save()
# Retrieve endpoints
@@ -870,11 +1099,13 @@ class CablePathTestCase(TestCase):
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable2.full_clean()
cable2.save()
# Retrieve endpoints
@@ -903,30 +1134,34 @@ class CablePathTestCase(TestCase):
def test_connection_via_patched_circuit(self):
"""
1 2 3 4
[Device 1] ----- [Panel 1] ----- [Circuit] ----- [Panel 2] ----- [Device 2]
[Device 1] ----- [Panel 5] ----- [Circuit] ----- [Panel 6] ----- [Device 2]
Iface1 FP1 RP1 A Z RP1 FP1 Iface1
"""
# Create cables
cable1 = Cable(
termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'),
termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1')
termination_b=FrontPort.objects.get(device__name='Panel 5', name='Front Port 1')
)
cable1.full_clean()
cable1.save()
cable2 = Cable(
termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'),
termination_a=RearPort.objects.get(device__name='Panel 5', name='Rear Port 1'),
termination_b=CircuitTermination.objects.get(term_side='A')
)
cable2.full_clean()
cable2.save()
cable3 = Cable(
termination_a=CircuitTermination.objects.get(term_side='Z'),
termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1')
termination_b=RearPort.objects.get(device__name='Panel 6', name='Rear Port 1')
)
cable3.full_clean()
cable3.save()
cable4 = Cable(
termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'),
termination_a=FrontPort.objects.get(device__name='Panel 6', name='Front Port 1'),
termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1')
)
cable4.full_clean()
cable4.save()
# Retrieve endpoints

View File

@@ -198,7 +198,7 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = {
'rack': rack.pk,
'units': [10, 11, 12],
'units': "10,11,12",
'user': user3.pk,
'tenant': None,
'description': 'Rack reservation',

View File

@@ -2057,7 +2057,7 @@ class CableTraceView(PermissionRequiredMixin, View):
def get(self, request, model, pk):
obj = get_object_or_404(model, pk=pk)
path, split_ends = obj.trace()
path, split_ends, position_stack = obj.trace()
total_length = sum(
[entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
)
@@ -2066,6 +2066,7 @@ class CableTraceView(PermissionRequiredMixin, View):
'obj': obj,
'trace': path,
'split_ends': split_ends,
'position_stack': position_stack,
'total_length': total_length,
})

View File

@@ -46,24 +46,19 @@ class WebhookAdmin(admin.ModelAdmin):
form = WebhookForm
fieldsets = (
(None, {
'fields': (
'name', 'obj_type', 'enabled',
)
'fields': ('name', 'obj_type', 'enabled')
}),
('Events', {
'fields': (
'type_create', 'type_update', 'type_delete',
)
'fields': ('type_create', 'type_update', 'type_delete')
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
)
),
'classes': ('monospace',)
}),
('SSL', {
'fields': (
'ssl_verification', 'ca_file_path',
)
'fields': ('ssl_verification', 'ca_file_path')
})
)
@@ -121,6 +116,8 @@ class CustomLinkForm(forms.ModelForm):
'url': forms.Textarea,
}
help_texts = {
'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
'first in a list.',
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
@@ -136,6 +133,15 @@ class CustomLinkForm(forms.ModelForm):
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
fieldsets = (
('Custom Link', {
'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
}),
('Templates', {
'fields': ('text', 'url'),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'group_name', 'weight',
]
@@ -149,8 +155,29 @@ class CustomLinkAdmin(admin.ModelAdmin):
# Graphs
#
class GraphForm(forms.ModelForm):
class Meta:
model = Graph
exclude = ()
widgets = {
'source': forms.Textarea,
'link': forms.Textarea,
}
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
fieldsets = (
('Graph', {
'fields': ('type', 'name', 'weight')
}),
('Templates', {
'fields': ('template_language', 'source', 'link'),
'classes': ('monospace',)
})
)
form = GraphForm
list_display = [
'name', 'type', 'weight', 'template_language', 'source',
]
@@ -179,6 +206,15 @@ class ExportTemplateForm(forms.ModelForm):
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
fieldsets = (
('Export Template', {
'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
}),
('Content', {
'fields': ('template_language', 'template_code'),
'classes': ('monospace',)
})
)
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension',
]

View File

@@ -1,15 +1,49 @@
from rest_framework import serializers
from extras.models import ReportResult
from extras import models
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedReportResultSerializer',
'NestedTagSerializer',
]
#
# Reports
#
class NestedConfigContextSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
class Meta:
model = models.ConfigContext
fields = ['id', 'url', 'name']
class NestedExportTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
class Meta:
model = models.ExportTemplate
fields = ['id', 'url', 'name']
class NestedGraphSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
class Meta:
model = models.Graph
fields = ['id', 'url', 'name']
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = models.Tag
fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
@@ -19,5 +53,5 @@ class NestedReportResultSerializer(serializers.ModelSerializer):
)
class Meta:
model = ReportResult
model = models.ReportResult
fields = ['url', 'created', 'user', 'failed']

View File

@@ -167,8 +167,14 @@ class AddRemoveTagsForm(forms.Form):
super().__init__(*args, **kwargs)
# Add add/remove tags fields
self.fields['add_tags'] = TagField(required=False)
self.fields['remove_tags'] = TagField(required=False)
self.fields['add_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
self.fields['remove_tags'] = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class TagFilterForm(BootstrapMixin, forms.Form):
@@ -430,18 +436,9 @@ class ScriptForm(BootstrapMixin, forms.Form):
help_text="Commit changes to the database (uncheck for a dry-run)"
)
def __init__(self, vars, *args, commit_default=True, **kwargs):
# Dynamically populate fields for variables
for name, var in vars.items():
self.base_fields[name] = var.as_field()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Toggle default commit behavior based on Meta option
if not commit_default:
self.fields['_commit'].initial = False
# Move _commit to the end of the form
commit = self.fields.pop('_commit')
self.fields['_commit'] = commit

View File

@@ -6,6 +6,7 @@ from django import get_version
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
@@ -52,6 +53,7 @@ class Command(BaseCommand):
pass
# Additional objects to include
namespace['ContentType'] = ContentType
namespace['User'] = User
# Load convenience commands

View File

@@ -167,7 +167,7 @@ class ChoiceVar(ScriptVariable):
class ObjectVar(ScriptVariable):
"""
NetBox object representation. The provided QuerySet will determine the choices available.
A single object within NetBox.
"""
form_field = DynamicModelChoiceField
@@ -276,13 +276,6 @@ class BaseScript:
@classmethod
def _get_vars(cls):
vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(cls.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(cls, name)
# Default to order of declaration on class
for name, attr in cls.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
@@ -296,8 +289,16 @@ class BaseScript:
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
vars = self._get_vars()
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
# Create a dynamic ScriptForm subclass from script variables
fields = {
name: var.as_field() for name, var in self._get_vars().items()
}
FormClass = type('ScriptForm', (ScriptForm,), fields)
form = FormClass(data, files, initial=initial)
# Set initial "commit" checkbox state based on the script's Meta parameter
form.fields['_commit'].initial = getattr(self.Meta, 'commit_default', True)
return form

View File

@@ -5,13 +5,11 @@ from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from extras.api.views import ScriptViewSet
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
@@ -24,489 +22,150 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class GraphTest(APITestCase):
def setUp(self):
super().setUp()
site_ct = ContentType.objects.get_for_model(Site)
self.graph1 = Graph.objects.create(
type=site_ct,
name='Test Graph 1',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
)
self.graph2 = Graph.objects.create(
type=site_ct,
name='Test Graph 2',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
)
self.graph3 = Graph.objects.create(
type=site_ct,
name='Test Graph 3',
source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
)
def test_get_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.graph1.name)
def test_list_graphs(self):
url = reverse('extras-api:graph-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_graph(self):
data = {
class GraphTest(APIViewTestCases.APIViewTestCase):
model = Graph
brief_fields = ['id', 'name', 'url']
create_data = [
{
'type': 'dcim.site',
'name': 'Test Graph 4',
'name': 'Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
}
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.objects.count(), 4)
graph4 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph4.type, ContentType.objects.get_for_model(Site))
self.assertEqual(graph4.name, data['name'])
self.assertEqual(graph4.source, data['source'])
def test_create_graph_bulk(self):
data = [
{
'type': 'dcim.site',
'name': 'Test Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
},
{
'type': 'dcim.site',
'name': 'Test Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': 'dcim.site',
'name': 'Test Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
url = reverse('extras-api:graph-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Graph.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_graph(self):
data = {
},
{
'type': 'dcim.site',
'name': 'Test Graph X',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
}
'name': 'Graph 5',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
},
{
'type': 'dcim.site',
'name': 'Graph 6',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
},
]
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.put(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Site)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Graph.objects.count(), 3)
graph1 = Graph.objects.get(pk=response.data['id'])
self.assertEqual(graph1.type, ContentType.objects.get_for_model(Site))
self.assertEqual(graph1.name, data['name'])
self.assertEqual(graph1.source, data['source'])
def test_delete_graph(self):
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Graph.objects.count(), 2)
class ExportTemplateTest(APITestCase):
def setUp(self):
super().setUp()
content_type = ContentType.objects.get_for_model(Device)
self.exporttemplate1 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate2 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
)
self.exporttemplate3 = ExportTemplate.objects.create(
content_type=content_type, name='Test Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
)
Graph.objects.bulk_create(graphs)
def test_get_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.exporttemplate1.name)
def test_list_exporttemplates(self):
url = reverse('extras-api:exporttemplate-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_exporttemplate(self):
data = {
class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
model = ExportTemplate
brief_fields = ['id', 'name', 'url']
create_data = [
{
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.objects.count(), 4)
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
self.assertEqual(exporttemplate4.name, data['name'])
self.assertEqual(exporttemplate4.template_code, data['template_code'])
def test_create_exporttemplate_bulk(self):
data = [
{
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
]
url = reverse('extras-api:exporttemplate-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ExportTemplate.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_exporttemplate(self):
data = {
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template X',
'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
}
},
{
'content_type': 'dcim.device',
'name': 'Test Export Template 6',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
},
]
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.put(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
ct = ContentType.objects.get_for_model(Device)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ExportTemplate.objects.count(), 3)
exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id'])
self.assertEqual(exporttemplate1.name, data['name'])
self.assertEqual(exporttemplate1.template_code, data['template_code'])
def test_delete_exporttemplate(self):
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ExportTemplate.objects.count(), 2)
class TagTest(APITestCase):
def setUp(self):
super().setUp()
self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
def test_get_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tag1.name)
def test_list_tags(self):
url = reverse('extras-api:tag-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_tag(self):
data = {
'name': 'Test Tag 4',
'slug': 'test-tag-4',
}
url = reverse('extras-api:tag-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tag.objects.count(), 4)
tag4 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag4.name, data['name'])
self.assertEqual(tag4.slug, data['slug'])
def test_create_tag_bulk(self):
data = [
{
'name': 'Test Tag 4',
'slug': 'test-tag-4',
},
{
'name': 'Test Tag 5',
'slug': 'test-tag-5',
},
{
'name': 'Test Tag 6',
'slug': 'test-tag-6',
},
]
url = reverse('extras-api:tag-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tag.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_tag(self):
data = {
'name': 'Test Tag X',
'slug': 'test-tag-x',
}
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tag.objects.count(), 3)
tag1 = Tag.objects.get(pk=response.data['id'])
self.assertEqual(tag1.name, data['name'])
self.assertEqual(tag1.slug, data['slug'])
def test_delete_tag(self):
url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Tag.objects.count(), 2)
class ConfigContextTest(APITestCase):
def setUp(self):
super().setUp()
self.configcontext1 = ConfigContext.objects.create(
name='Test Config Context 1',
weight=100,
data={'foo': 123}
export_templates = (
ExportTemplate(
content_type=ct,
name='Export Template 1',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
ExportTemplate(
content_type=ct,
name='Export Template 2',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
ExportTemplate(
content_type=ct,
name='Export Template 3',
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
),
)
self.configcontext2 = ConfigContext.objects.create(
name='Test Config Context 2',
weight=200,
data={'bar': 456}
ExportTemplate.objects.bulk_create(export_templates)
class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag
brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url']
create_data = [
{
'name': 'Tag 4',
'slug': 'tag-4',
},
{
'name': 'Tag 5',
'slug': 'tag-5',
},
{
'name': 'Tag 6',
'slug': 'tag-6',
},
]
@classmethod
def setUpTestData(cls):
tags = (
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'),
)
self.configcontext3 = ConfigContext.objects.create(
name='Test Config Context 3',
weight=300,
data={'baz': 789}
Tag.objects.bulk_create(tags)
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
model = ConfigContext
brief_fields = ['id', 'name', 'url']
create_data = [
{
'name': 'Config Context 4',
'data': {'more_foo': True},
},
{
'name': 'Config Context 5',
'data': {'more_bar': False},
},
{
'name': 'Config Context 6',
'data': {'more_baz': None},
},
]
@classmethod
def setUpTestData(cls):
config_contexts = (
ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
)
def test_get_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.configcontext1.name)
self.assertEqual(response.data['data'], self.configcontext1.data)
def test_list_configcontexts(self):
url = reverse('extras-api:configcontext-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_create_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
tenantgroup1 = TenantGroup(name='Test Tenant Group 1', slug='test-tenant-group-1')
tenantgroup1.save()
tenantgroup2 = TenantGroup(name='Test Tenant Group 2', slug='test-tenant-group-2')
tenantgroup2.save()
tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
data = {
'name': 'Test Config Context 4',
'weight': 1000,
'regions': [region1.pk, region2.pk],
'sites': [site1.pk, site2.pk],
'roles': [role1.pk, role2.pk],
'platforms': [platform1.pk, platform2.pk],
'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk],
'tenants': [tenant1.pk, tenant2.pk],
'tags': [tag1.slug, tag2.slug],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ConfigContext.objects.count(), 4)
configcontext4 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext4.name, data['name'])
self.assertEqual(region1.pk, data['regions'][0])
self.assertEqual(region2.pk, data['regions'][1])
self.assertEqual(site1.pk, data['sites'][0])
self.assertEqual(site2.pk, data['sites'][1])
self.assertEqual(role1.pk, data['roles'][0])
self.assertEqual(role2.pk, data['roles'][1])
self.assertEqual(platform1.pk, data['platforms'][0])
self.assertEqual(platform2.pk, data['platforms'][1])
self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0])
self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1])
self.assertEqual(tenant1.pk, data['tenants'][0])
self.assertEqual(tenant2.pk, data['tenants'][1])
self.assertEqual(tag1.slug, data['tags'][0])
self.assertEqual(tag2.slug, data['tags'][1])
self.assertEqual(configcontext4.data, data['data'])
def test_create_configcontext_bulk(self):
data = [
{
'name': 'Test Config Context 4',
'data': {'more_foo': True},
},
{
'name': 'Test Config Context 5',
'data': {'more_bar': False},
},
{
'name': 'Test Config Context 6',
'data': {'more_baz': None},
},
]
url = reverse('extras-api:configcontext-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ConfigContext.objects.count(), 6)
for i in range(0, 3):
self.assertEqual(response.data[i]['name'], data[i]['name'])
self.assertEqual(response.data[i]['data'], data[i]['data'])
def test_update_configcontext(self):
region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
data = {
'name': 'Test Config Context X',
'weight': 999,
'regions': [region1.pk, region2.pk],
'data': {'foo': 'XXX'}
}
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ConfigContext.objects.count(), 3)
configcontext1 = ConfigContext.objects.get(pk=response.data['id'])
self.assertEqual(configcontext1.name, data['name'])
self.assertEqual(configcontext1.weight, data['weight'])
self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions']))
self.assertEqual(configcontext1.data, data['data'])
def test_delete_configcontext(self):
url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ConfigContext.objects.count(), 2)
ConfigContext.objects.bulk_create(config_contexts)
def test_render_configcontext_for_object(self):
# Create a Device for which we'll render a config context
manufacturer = Manufacturer.objects.create(
name='Test Manufacturer',
slug='test-manufacturer'
)
device_type = DeviceType.objects.create(
manufacturer=manufacturer,
model='Test Device Type'
)
device_role = DeviceRole.objects.create(
name='Test Role',
slug='test-role'
)
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
device = Device.objects.create(
name='Test Device',
device_type=device_type,
device_role=device_role,
site=site
)
"""
Test rendering config context data for a device.
"""
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
site = Site.objects.create(name='Site-1', slug='site-1')
device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site)
# Test default config contexts (created at test setup)
rendered_context = device.get_config_context()
@@ -516,7 +175,7 @@ class ConfigContextTest(APITestCase):
# Add another context specific to the site
configcontext4 = ConfigContext(
name='Test Config Context 4',
name='Config Context 4',
data={'site_data': 'ABC'}
)
configcontext4.save()
@@ -526,7 +185,7 @@ class ConfigContextTest(APITestCase):
# Override one of the default contexts
configcontext5 = ConfigContext(
name='Test Config Context 5',
name='Config Context 5',
weight=2000,
data={'foo': 999}
)
@@ -536,12 +195,9 @@ class ConfigContextTest(APITestCase):
self.assertEqual(rendered_context['foo'], 999)
# Add a context which does NOT match our device and ensure it does not apply
site2 = Site.objects.create(
name='Test Site 2',
slug='test-site-2'
)
site2 = Site.objects.create(name='Site 2', slug='site-2')
configcontext6 = ConfigContext(
name='Test Config Context 6',
name='Config Context 6',
weight=2000,
data={'bar': 999}
)

View File

@@ -436,7 +436,6 @@ class ScriptView(PermissionRequiredMixin, View):
raise Http404
def get(self, request, module, name):
script = self._get_script(module, name)
form = script.as_form(initial=request.GET)

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF
from ipam import models
from utilities.api import WritableNestedSerializer
__all__ = [
@@ -9,6 +9,7 @@ __all__ = [
'NestedPrefixSerializer',
'NestedRIRSerializer',
'NestedRoleSerializer',
'NestedServiceSerializer',
'NestedVLANGroupSerializer',
'NestedVLANSerializer',
'NestedVRFSerializer',
@@ -24,7 +25,7 @@ class NestedVRFSerializer(WritableNestedSerializer):
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VRF
model = models.VRF
fields = ['id', 'url', 'name', 'rd', 'prefix_count']
@@ -37,7 +38,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
aggregate_count = serializers.IntegerField(read_only=True)
class Meta:
model = RIR
model = models.RIR
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
@@ -45,7 +46,7 @@ class NestedAggregateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
class Meta:
model = Aggregate
model = models.Aggregate
fields = ['id', 'url', 'family', 'prefix']
@@ -59,7 +60,7 @@ class NestedRoleSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Role
model = models.Role
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
@@ -68,7 +69,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer):
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
model = models.VLANGroup
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
@@ -76,7 +77,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
class Meta:
model = VLAN
model = models.VLAN
fields = ['id', 'url', 'vid', 'name', 'display_name']
@@ -88,7 +89,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
class Meta:
model = Prefix
model = models.Prefix
fields = ['id', 'url', 'family', 'prefix']
@@ -96,10 +97,21 @@ class NestedPrefixSerializer(WritableNestedSerializer):
# IP addresses
#
class NestedIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
model = models.IPAddress
fields = ['id', 'url', 'family', 'address']
#
# Services
#
class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
class Meta:
model = models.Service
fields = ['id', 'url', 'name', 'protocol', 'port']

View File

@@ -74,12 +74,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilterSet
@swagger_auto_schema(
methods=['get', 'post'],
responses={
200: serializers.AvailablePrefixSerializer(many=True),
}
)
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
@@ -94,10 +90,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_prefix'):
raise PermissionDenied()
# Validate Requested Prefixes' length
serializer = serializers.PrefixLengthSerializer(
data=request.data if isinstance(request.data, list) else [request.data],
@@ -158,13 +150,10 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
@swagger_auto_schema(
methods=['get', 'post'],
responses={
200: serializers.AvailableIPSerializer(many=True),
}
)
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
request_body=serializers.AvailableIPSerializer(many=False))
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):
"""
@@ -180,10 +169,6 @@ class PrefixViewSet(CustomFieldModelViewSet):
# Create the next available IP within the prefix
if request.method == 'POST':
# Permissions check
if not request.user.has_perm('ipam.add_ipaddress'):
raise PermissionDenied()
# Normalize to a list of objects
requested_ips = request.data if isinstance(request.data, list) else [request.data]
@@ -276,7 +261,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
prefix_count=get_subquery(Prefix, 'role')
prefix_count=get_subquery(Prefix, 'vlan')
)
serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet

View File

@@ -681,11 +681,14 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False,
label='VRF'
)
tags = TagField(
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,11 @@ ADMINS = [
# ['John Doe', 'jdoe@example.com'],
]
# URL schemes that are allowed within links in NetBox
ALLOWED_URL_SCHEMES = (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
)
# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same
# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP.
BANNER_TOP = ''
@@ -203,6 +208,10 @@ PLUGINS = []
# prefer IPv4 instead.
PREFER_IPV4 = False
# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1.
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220
# Remote authentication support
REMOTE_AUTH_ENABLED = False
REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend'

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.8.5'
VERSION = '2.8.7'
# Hostname
HOSTNAME = platform.node()
@@ -58,6 +58,9 @@ SECRET_KEY = getattr(configuration, 'SECRET_KEY')
# Set optional parameters
ADMINS = getattr(configuration, 'ADMINS', [])
ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', (
'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp',
))
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
@@ -96,6 +99,8 @@ PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22)
RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])

View File

@@ -183,13 +183,6 @@ nav ul.pagination {
margin-bottom: 8px !important;
}
/* Racks */
div.rack_header {
margin-left: 32px;
text-align: center;
width: 220px;
}
/* Devices */
table.component-list td.subtable {
padding: 0;

View File

@@ -1,13 +1,22 @@
from rest_framework import serializers
from secrets.models import SecretRole
from secrets.models import Secret, SecretRole
from utilities.api import WritableNestedSerializer
__all__ = [
'NestedSecretRoleSerializer'
'NestedSecretRoleSerializer',
'NestedSecretSerializer',
]
class NestedSecretSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail')
class Meta:
model = Secret
fields = ['id', 'url', 'name']
class NestedSecretRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail')
secret_count = serializers.IntegerField(read_only=True)

View File

@@ -115,6 +115,16 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
'plaintext2': "The two given plaintext values do not match. Please check your input."
})
# Validate uniqueness
if Secret.objects.filter(
device=self.cleaned_data['device'],
role=self.cleaned_data['role'],
name=self.cleaned_data['name']
).exists():
raise forms.ValidationError(
"Each secret assigned to a device must have a unique combination of role and name"
)
class SecretCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(

View File

@@ -6,7 +6,7 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey
from users.models import Token
from utilities.testing import APITestCase, create_test_user
from utilities.testing import APITestCase, APIViewTestCases, create_test_user
from .constants import PRIVATE_KEY, PUBLIC_KEY
@@ -20,312 +20,97 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class SecretRoleTest(APITestCase):
class SecretRoleTest(APIViewTestCases.APIViewTestCase):
model = SecretRole
brief_fields = ['id', 'name', 'secret_count', 'slug', 'url']
create_data = [
{
'name': 'Secret Role 4',
'slug': 'secret-role-4',
},
{
'name': 'Secret Role 5',
'slug': 'secret-role-5',
},
{
'name': 'Secret Role 6',
'slug': 'secret-role-6',
},
]
@classmethod
def setUpTestData(cls):
secret_roles = (
SecretRole(name='Secret Role 1', slug='secret-role-1'),
SecretRole(name='Secret Role 2', slug='secret-role-2'),
SecretRole(name='Secret Role 3', slug='secret-role-3'),
)
SecretRole.objects.bulk_create(secret_roles)
class SecretTest(APIViewTestCases.APIViewTestCase):
model = Secret
brief_fields = ['id', 'name', 'url']
def setUp(self):
super().setUp()
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.secretrole3 = SecretRole.objects.create(name='Test Secret Role 3', slug='test-secret-role-3')
def test_get_secretrole(self):
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.secretrole1.name)
def test_list_secretroles(self):
url = reverse('secrets-api:secretrole-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_secretroles_brief(self):
url = reverse('secrets-api:secretrole-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'secret_count', 'slug', 'url']
)
def test_create_secretrole(self):
data = {
'name': 'Test Secret Role 4',
'slug': 'test-secret-role-4',
}
url = reverse('secrets-api:secretrole-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(SecretRole.objects.count(), 4)
secretrole4 = SecretRole.objects.get(pk=response.data['id'])
self.assertEqual(secretrole4.name, data['name'])
self.assertEqual(secretrole4.slug, data['slug'])
def test_create_secretrole_bulk(self):
data = [
{
'name': 'Test Secret Role 4',
'slug': 'test-secret-role-4',
},
{
'name': 'Test Secret Role 5',
'slug': 'test-secret-role-5',
},
{
'name': 'Test Secret Role 6',
'slug': 'test-secret-role-6',
},
]
url = reverse('secrets-api:secretrole-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(SecretRole.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_secretrole(self):
data = {
'name': 'Test SecretRole X',
'slug': 'test-secretrole-x',
}
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(SecretRole.objects.count(), 3)
secretrole1 = SecretRole.objects.get(pk=response.data['id'])
self.assertEqual(secretrole1.name, data['name'])
self.assertEqual(secretrole1.slug, data['slug'])
def test_delete_secretrole(self):
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(SecretRole.objects.count(), 2)
class SecretTest(APITestCase):
def setUp(self):
# 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)}
# Create a UserKey for the test user
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
# Create a SessionKey for the user
self.master_key = userkey.get_master_key(PRIVATE_KEY)
session_key = SessionKey(userkey=userkey)
session_key.save(self.master_key)
self.header = {
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
'HTTP_X_SESSION_KEY': base64.b64encode(session_key.key),
}
# Append the session key to the test client's request header
self.header['HTTP_X_SESSION_KEY'] = base64.b64encode(session_key.key)
self.plaintexts = (
'Secret #1 Plaintext',
'Secret #2 Plaintext',
'Secret #3 Plaintext',
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)
secret_roles = (
SecretRole(name='Secret Role 1', slug='secret-role-1'),
SecretRole(name='Secret Role 2', slug='secret-role-2'),
)
SecretRole.objects.bulk_create(secret_roles)
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')
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1')
self.device = Device.objects.create(
name='Test Device 1', site=site, device_type=devicetype, device_role=devicerole
secrets = (
Secret(device=device, role=secret_roles[0], name='Secret 1', plaintext='ABC'),
Secret(device=device, role=secret_roles[0], name='Secret 2', plaintext='DEF'),
Secret(device=device, role=secret_roles[0], name='Secret 3', plaintext='GHI'),
)
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.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.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.plaintexts[2]
)
self.secret3.encrypt(self.master_key)
self.secret3.save()
for secret in secrets:
secret.encrypt(self.master_key)
secret.save()
def test_get_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
# 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')
# 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):
data = {
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext',
}
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['plaintext'], data['plaintext'])
self.assertEqual(Secret.objects.count(), 4)
secret4 = Secret.objects.get(pk=response.data['id'])
secret4.decrypt(self.master_key)
self.assertEqual(secret4.role_id, data['role'])
self.assertEqual(secret4.plaintext, data['plaintext'])
def test_create_secret_bulk(self):
data = [
self.create_data = [
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 4',
'plaintext': 'Secret #4 Plaintext',
'device': device.pk,
'role': secret_roles[1].pk,
'name': 'Secret 4',
'plaintext': 'JKL',
},
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 5',
'plaintext': 'Secret #5 Plaintext',
'device': device.pk,
'role': secret_roles[1].pk,
'name': 'Secret 5',
'plaintext': 'MNO',
},
{
'device': self.device.pk,
'role': self.secretrole1.pk,
'name': 'Test Secret 6',
'plaintext': 'Secret #6 Plaintext',
'device': device.pk,
'role': secret_roles[1].pk,
'name': 'Secret 6',
'plaintext': 'PQR',
},
]
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Secret.objects.count(), 6)
self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
def test_update_secret(self):
data = {
'device': self.device.pk,
'role': self.secretrole2.pk,
'plaintext': 'NewPlaintext',
}
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['plaintext'], data['plaintext'])
self.assertEqual(Secret.objects.count(), 3)
secret1 = Secret.objects.get(pk=response.data['id'])
secret1.decrypt(self.master_key)
self.assertEqual(secret1.role_id, data['role'])
self.assertEqual(secret1.plaintext, data['plaintext'])
def test_delete_secret(self):
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Secret.objects.count(), 2)
class GetSessionKeyTest(APITestCase):
def setUp(self):
super().setUp()
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.header = {
'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key),
}
def test_get_session_key(self):
encoded_session_key = base64.b64encode(self.session_key.key).decode()
url = reverse('secrets-api:get-session-key-list')
data = {
'private_key': PRIVATE_KEY,
}
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIsNotNone(response.data.get('session_key'))
self.assertNotEqual(response.data.get('session_key'), encoded_session_key)
def test_get_session_key_preserved(self):
encoded_session_key = base64.b64encode(self.session_key.key).decode()
url = reverse('secrets-api:get-session-key-list') + '?preserve_key=True'
data = {
'private_key': PRIVATE_KEY,
}
response = self.client.post(url, data, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data.get('session_key'), encoded_session_key)
def prepare_instance(self, instance):
# Unlock the plaintext prior to evaluation of the instance
instance.decrypt(self.master_key)
return instance

View File

@@ -88,6 +88,16 @@
</table>
</div>
</div>
{% elif position_stack %}
<div class="col-md-11 col-md-offset-1">
<h3 class="text-warning text-center">
{% with last_position=position_stack|last %}
Trace completed, but there is no Front Port corresponding to
<a href="{{ last_position.device.get_absolute_url }}">{{ last_position.device }}</a> {{ last_position }}.<br>
Therefore no end-to-end connection can be established.
{% endwith %}
</h3>
</div>
{% else %}
<div class="col-md-11 col-md-offset-1">
<h3 class="text-success text-center">Trace completed!</h3>

View File

@@ -9,12 +9,12 @@
<div class="panel-footer noprint">
{% if table.rows %}
{% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<button type="submit" name="_edit" formaction="{% url edit_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button>
{% endif %}
{% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<button type="submit" name="_delete" formaction="{% url delete_url %}?device_type={{ devicetype.pk }}&return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button>
{% endif %}

View File

@@ -1,4 +1,6 @@
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" class="rack_elevation"></object>
<div style="margin-left: -30px">
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" class="rack_elevation"></object>
</div>
<div class="text-center text-small">
<a href="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg">
<i class="fa fa-download"></i> Save SVG

View File

@@ -318,16 +318,12 @@
</div>
<div class="col-md-6">
<div class="row" style="margin-bottom: 20px">
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Front</h4>
</div>
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
<h4>Front</h4>
{% include 'dcim/inc/rack_elevation.html' with face='front' %}
</div>
<div class="col-md-6 col-sm-6 col-xs-12">
<div class="rack_header">
<h4>Rear</h4>
</div>
<div class="col-md-6 col-sm-6 col-xs-12 text-center">
<h4>Rear</h4>
{% include 'dcim/inc/rack_elevation.html' with face='rear' %}
</div>
</div>

View File

@@ -3,19 +3,21 @@
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
<div class="panel-heading"><strong>Rack Reservation</strong></div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">Rack</label>
<div class="col-md-9">
<p class="form-control-static">{{ obj.rack }}</p>
</div>
</div>
{% render_field form.site %}
{% render_field form.rack_group %}
{% render_field form.rack %}
{% render_field form.units %}
{% render_field form.user %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.description %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenant Assignment</strong></div>
<div class="panel-body">
{% render_field form.tenant_group %}
{% render_field form.tenant %}
</div>
</div>
{% endblock %}

View File

@@ -70,6 +70,7 @@
<li{% if not perms.dcim.view_rackreservation %} class="disabled"{% endif %}>
{% if perms.dcim.add_rackreservation %}
<div class="buttons pull-right">
<a href="{% url 'dcim:rackreservation_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'dcim:rackreservation_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}

View File

@@ -19,21 +19,21 @@
{% endif %}
</ul>
</nav>
<form method="get">
{% for k, v_list in request.GET.lists %}
{% if k != 'per_page' %}
{% for v in v_list %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{% endif %}
{% endfor %}
<select name="per_page" id="per_page">
{% for n in settings.PER_PAGE_DEFAULTS %}
<option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
{% endfor %}
</select> per page
</form>
{% endif %}
<form method="get">
{% for k, v_list in request.GET.lists %}
{% if k != 'per_page' %}
{% for v in v_list %}
<input type="hidden" name="{{ k }}" value="{{ v }}" />
{% endfor %}
{% endif %}
{% endfor %}
<select name="per_page" id="per_page">
{% for n in settings.PER_PAGE_DEFAULTS %}
<option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
{% endfor %}
</select> per page
</form>
{% if page %}
<div class="text-right text-muted">
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}

View File

@@ -26,6 +26,12 @@
{% render_field model_form.tenant %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field model_form.tags %}
</div>
</div>
{% if model_form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>

View File

@@ -64,7 +64,7 @@
<li role="presentation"{% if active_tab == 'prefixes' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_prefixes' pk=prefix.pk %}">Child Prefixes <span class="badge">{{ prefix.get_child_prefixes.count }}</span></a>
</li>
{% if perms.ipam.view_ipaddress %}
{% if perms.ipam.view_ipaddress and prefix.status != 'container' %}
<li role="presentation"{% if active_tab == 'ip-addresses' %} class="active"{% endif %}>
<a href="{% url 'ipam:prefix_ipaddresses' pk=prefix.pk %}">IP Addresses <span class="badge">{{ prefix.get_child_ips.count }}</span></a>
</li>

View File

@@ -1,8 +1,7 @@
from django.urls import reverse
from rest_framework import status
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase
from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
@@ -15,235 +14,74 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class TenantGroupTest(APITestCase):
class TenantGroupTest(APIViewTestCases.APIViewTestCase):
model = TenantGroup
brief_fields = ['id', 'name', 'slug', 'tenant_count', 'url']
def setUp(self):
@classmethod
def setUpTestData(cls):
super().setUp()
self.parent_tenant_groups = (
TenantGroup(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
TenantGroup(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
)
for tenantgroup in self.parent_tenant_groups:
tenantgroup.save()
self.tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1', parent=self.parent_tenant_groups[0]),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2', parent=self.parent_tenant_groups[0]),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3', parent=self.parent_tenant_groups[0]),
)
for tenantgroup in self.tenant_groups:
tenantgroup.save()
def test_get_tenantgroup(self):
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tenant_groups[0].name)
def test_list_tenantgroups(self):
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 5)
def test_list_tenantgroups_brief(self):
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'tenant_count', 'url']
parent_tenant_groups = (
TenantGroup.objects.create(name='Parent Tenant Group 1', slug='parent-tenant-group-1'),
TenantGroup.objects.create(name='Parent Tenant Group 2', slug='parent-tenant-group-2'),
)
def test_create_tenantgroup(self):
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1', parent=parent_tenant_groups[0])
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2', parent=parent_tenant_groups[0])
TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3', parent=parent_tenant_groups[0])
data = {
'name': 'Tenant Group 4',
'slug': 'tenant-group-4',
'parent': self.parent_tenant_groups[0].pk,
}
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(TenantGroup.objects.count(), 6)
tenantgroup4 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup4.name, data['name'])
self.assertEqual(tenantgroup4.slug, data['slug'])
self.assertEqual(tenantgroup4.parent_id, data['parent'])
def test_create_tenantgroup_bulk(self):
data = [
cls.create_data = [
{
'name': 'Tenant Group 4',
'slug': 'tenant-group-4',
'parent': self.parent_tenant_groups[0].pk,
'parent': parent_tenant_groups[1].pk,
},
{
'name': 'Tenant Group 5',
'slug': 'tenant-group-5',
'parent': self.parent_tenant_groups[0].pk,
'parent': parent_tenant_groups[1].pk,
},
{
'name': 'Tenant Group 6',
'slug': 'tenant-group-6',
'parent': self.parent_tenant_groups[0].pk,
'parent': parent_tenant_groups[1].pk,
},
]
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(TenantGroup.objects.count(), 8)
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'])
class TenantTest(APIViewTestCases.APIViewTestCase):
model = Tenant
brief_fields = ['id', 'name', 'slug', 'url']
def test_update_tenantgroup(self):
@classmethod
def setUpTestData(cls):
data = {
'name': 'Tenant Group X',
'slug': 'tenant-group-x',
'parent': self.parent_tenant_groups[1].pk,
}
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(TenantGroup.objects.count(), 5)
tenantgroup1 = TenantGroup.objects.get(pk=response.data['id'])
self.assertEqual(tenantgroup1.name, data['name'])
self.assertEqual(tenantgroup1.slug, data['slug'])
self.assertEqual(tenantgroup1.parent_id, data['parent'])
def test_delete_tenantgroup(self):
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenant_groups[0].pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(TenantGroup.objects.count(), 4)
class TenantTest(APITestCase):
def setUp(self):
super().setUp()
self.tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
)
for tenantgroup in self.tenant_groups:
tenantgroup.save()
self.tenants = (
Tenant(name='Test Tenant 1', slug='test-tenant-1', group=self.tenant_groups[0]),
Tenant(name='Test Tenant 2', slug='test-tenant-2', group=self.tenant_groups[0]),
Tenant(name='Test Tenant 3', slug='test-tenant-3', group=self.tenant_groups[0]),
)
Tenant.objects.bulk_create(self.tenants)
def test_get_tenant(self):
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.tenants[0].name)
def test_list_tenants(self):
url = reverse('tenancy-api:tenant-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_tenants_brief(self):
url = reverse('tenancy-api:tenant-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
tenant_groups = (
TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2'),
)
def test_create_tenant(self):
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[0]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[0]),
)
Tenant.objects.bulk_create(tenants)
data = {
'name': 'Test Tenant 4',
'slug': 'test-tenant-4',
'group': self.tenant_groups[0].pk,
}
url = reverse('tenancy-api:tenant-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tenant.objects.count(), 4)
tenant4 = Tenant.objects.get(pk=response.data['id'])
self.assertEqual(tenant4.name, data['name'])
self.assertEqual(tenant4.slug, data['slug'])
self.assertEqual(tenant4.group_id, data['group'])
def test_create_tenant_bulk(self):
data = [
cls.create_data = [
{
'name': 'Test Tenant 4',
'slug': 'test-tenant-4',
'name': 'Tenant 4',
'slug': 'tenant-4',
'group': tenant_groups[1].pk,
},
{
'name': 'Test Tenant 5',
'slug': 'test-tenant-5',
'name': 'Tenant 5',
'slug': 'tenant-5',
'group': tenant_groups[1].pk,
},
{
'name': 'Test Tenant 6',
'slug': 'test-tenant-6',
'name': 'Tenant 6',
'slug': 'tenant-6',
'group': tenant_groups[1].pk,
},
]
url = reverse('tenancy-api:tenant-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Tenant.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_tenant(self):
data = {
'name': 'Test Tenant X',
'slug': 'test-tenant-x',
'group': self.tenant_groups[1].pk,
}
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Tenant.objects.count(), 3)
tenant1 = Tenant.objects.get(pk=response.data['id'])
self.assertEqual(tenant1.name, data['name'])
self.assertEqual(tenant1.slug, data['slug'])
self.assertEqual(tenant1.group_id, data['group'])
def test_delete_tenant(self):
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenants[0].pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Tenant.objects.count(), 2)

View File

@@ -50,7 +50,7 @@ class LoginView(View):
logger.debug("Login form validation was successful")
# Determine where to direct user after successful login
redirect_to = request.POST.get('next')
redirect_to = request.POST.get('next', reverse('home'))
if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}")
redirect_to = reverse('home')

View File

@@ -6,14 +6,13 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import ManyToManyField, ProtectedError
from django.http import Http404
from django.urls import reverse
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
from .utils import dict_to_filter_params, dynamic_import

View File

@@ -7,6 +7,7 @@ import django_filters
import yaml
from django import forms
from django.conf import settings
from django.contrib.postgres.forms import SimpleArrayField
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Count
@@ -243,24 +244,11 @@ class ContentTypeSelect(StaticSelect2):
option_template_name = 'widgets/select_contenttype.html'
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
"""
MultiSelect widget for a SimpleArrayField. Choices must be populated on the widget.
"""
def __init__(self, *args, **kwargs):
self.delimiter = kwargs.pop('delimiter', ',')
super().__init__(*args, **kwargs)
class NumericArrayField(SimpleArrayField):
def optgroups(self, name, value, attrs=None):
# Split the delimited string of values into a list
if value:
value = value[0].split(self.delimiter)
return super().optgroups(name, value, attrs)
def value_from_datadict(self, data, files, name):
# Condense the list of selected choices into a delimited string
data = super().value_from_datadict(data, files, name)
return self.delimiter.join(data)
def to_python(self, value):
value = ','.join([str(n) for n in parse_numeric_range(value)])
return super().to_python(value)
class APISelect(SelectWithDisabled):
@@ -659,9 +647,8 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
class LaxURLField(forms.URLField):
"""
Modifies Django's built-in URLField in two ways:
1) Allow any valid scheme per RFC 3986 section 3.1
2) Remove the requirement for fully-qualified domain names (e.g. http://myserver/ is valid)
Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
(e.g. http://myserver/ is valid)
"""
default_validators = [EnhancedURLValidator()]

View File

@@ -0,0 +1,19 @@
from rest_framework.metadata import SimpleMetadata
from django.utils.encoding import force_str
from utilities.api import ContentTypeField
class ContentTypeMetadata(SimpleMetadata):
def get_field_info(self, field):
field_info = super().get_field_info(field)
if hasattr(field, 'queryset') and not field_info.get('read_only') and isinstance(field, ContentTypeField):
field_info['choices'] = [
{
'value': choice_value,
'display_name': force_str(choice_name, strings_only=True)
}
for choice_value, choice_name in field.choices.items()
]
field_info['choices'].sort(key=lambda item: item['display_name'])
return field_info

View File

@@ -84,6 +84,10 @@ class BaseTable(tables.Table):
return [name for name in self.sequence if self.columns[name].visible]
#
# Table columns
#
class ToggleColumn(tables.CheckBoxColumn):
"""
Extend CheckBoxColumn to add a "toggle all" checkbox in the column header.
@@ -129,6 +133,19 @@ class ColorColumn(tables.Column):
)
class ColoredLabelColumn(tables.TemplateColumn):
"""
Render a colored label (e.g. for DeviceRoles).
"""
template_code = """
{% load helpers %}
{% if value %}<label class="label" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
class TagColumn(tables.TemplateColumn):
"""
Display a list of tags assigned to the object.

View File

@@ -10,7 +10,6 @@ from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from markdown import markdown
from utilities.choices import unpack_grouped_choices
from utilities.utils import foreground_color
register = template.Library()
@@ -39,6 +38,11 @@ def render_markdown(value):
# Strip HTML tags
value = strip_tags(value)
# Sanitize Markdown links
schemes = '|'.join(settings.ALLOWED_URL_SCHEMES)
pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
# Render Markdown
html = markdown(value, extensions=['fenced_code', 'tables'])

View File

@@ -1,8 +1,12 @@
from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings
from django.urls import reverse, NoReverseMatch
from netaddr import IPNetwork
from rest_framework import status
from rest_framework.test import APIClient
from users.models import Token
@@ -22,6 +26,54 @@ class TestCase(_TestCase):
self.client = Client()
self.client.force_login(self.user)
def prepare_instance(self, instance):
"""
Test cases can override this method to perform any necessary manipulation of an instance prior to its evaluation
against test data. For example, it can be used to decrypt a Secret's plaintext attribute.
"""
return instance
def model_to_dict(self, instance, fields, api=False):
"""
Return a dictionary representation of an instance.
"""
# Prepare the instance and call Django's model_to_dict() to extract all fields
model_dict = model_to_dict(self.prepare_instance(instance), fields=fields)
# Map any additional (non-field) instance attributes that were specified
for attr in fields:
if hasattr(instance, attr) and attr not in model_dict:
model_dict[attr] = getattr(instance, attr)
for key, value in list(model_dict.items()):
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
if key == 'tags':
model_dict[key] = ','.join(sorted([tag.name for tag in value]))
# Convert ManyToManyField to list of instance PKs
elif model_dict[key] and type(value) in (list, tuple) and hasattr(value[0], 'pk'):
model_dict[key] = [obj.pk for obj in value]
if api:
# Replace ContentType numeric IDs with <app_label>.<model>
if type(getattr(instance, key)) is ContentType:
ct = ContentType.objects.get(pk=value)
model_dict[key] = f'{ct.app_label}.{ct.model}'
# Convert IPNetwork instances to strings
if type(value) is IPNetwork:
model_dict[key] = str(value)
else:
# Convert ArrayFields to CSV strings
if type(instance._meta.get_field(key)) is ArrayField:
model_dict[key] = ','.join([str(v) for v in value])
return model_dict
#
# Permissions management
#
@@ -57,6 +109,28 @@ class TestCase(_TestCase):
expected_status, response.status_code, getattr(response, 'data', 'No data')
))
def assertInstanceEqual(self, instance, data, api=False):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary.
:instance: Python object instance
:data: Dictionary of test data used to define the instance
:api: Set to True is the data is a JSON representation of the instance
"""
model_dict = self.model_to_dict(instance, fields=data.keys(), api=api)
# Omit any dictionary keys which are not instance attributes
relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k)
}
self.assertDictEqual(model_dict, relevant_data)
#
# UI Tests
#
class ModelViewTestCase(TestCase):
"""
@@ -104,42 +178,6 @@ class ModelViewTestCase(TestCase):
else:
raise Exception("Invalid action for URL resolution: {}".format(action))
def assertInstanceEqual(self, instance, data):
"""
Compare a model instance to a dictionary, checking that its attribute values match those specified
in the dictionary.
"""
model_dict = model_to_dict(instance, fields=data.keys())
for key in list(model_dict.keys()):
# TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
if key == 'tags':
model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
# Convert ManyToManyField to list of instance PKs
elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
model_dict[key] = [obj.pk for obj in model_dict[key]]
# Omit any dictionary keys which are not instance attributes
relevant_data = {
k: v for k, v in data.items() if hasattr(instance, k)
}
self.assertDictEqual(model_dict, relevant_data)
class APITestCase(TestCase):
client_class = APIClient
def setUp(self):
"""
Create a superuser and token for API calls.
"""
self.user = User.objects.create(username='testuser', is_superuser=True)
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
class ViewTestCases:
"""
@@ -165,7 +203,7 @@ class ViewTestCases:
self.assertHttpStatus(response, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_list_objects_anonymous(self):
def test_get_object_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
response = self.client.get(self.model.objects.first().get_absolute_url())
@@ -182,7 +220,7 @@ class ViewTestCases:
# Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('add')), 403)
self.assertHttpStatus(self.client.get(self._get_url('add')), 403)
# Try GET with permission
self.add_permissions(
@@ -218,7 +256,7 @@ class ViewTestCases:
# Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403)
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 403)
# Try GET with permission
self.add_permissions(
@@ -250,7 +288,7 @@ class ViewTestCases:
# Try GET without permissions
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403)
self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403)
# Try GET with permission
self.add_permissions(
@@ -488,3 +526,129 @@ class ViewTestCases:
TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
"""
maxDiff = None
#
# REST API Tests
#
class APITestCase(TestCase):
client_class = APIClient
model = None
def setUp(self):
"""
Create a superuser and token for API calls.
"""
self.user = User.objects.create(username='testuser', is_superuser=True)
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
def _get_detail_url(self, instance):
viewname = f'{instance._meta.app_label}-api:{instance._meta.model_name}-detail'
return reverse(viewname, kwargs={'pk': instance.pk})
def _get_list_url(self):
viewname = f'{self.model._meta.app_label}-api:{self.model._meta.model_name}-list'
return reverse(viewname)
class APIViewTestCases:
class GetObjectViewTestCase(APITestCase):
def test_get_object(self):
"""
GET a single object identified by its numeric ID.
"""
instance = self.model.objects.first()
url = self._get_detail_url(instance)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['id'], instance.pk)
class ListObjectsViewTestCase(APITestCase):
brief_fields = []
def test_list_objects(self):
"""
GET a list of objects.
"""
url = self._get_list_url()
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data['results']), self.model.objects.count())
def test_list_objects_brief(self):
"""
GET a list of objects using the "brief" parameter.
"""
url = f'{self._get_list_url()}?brief=1'
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data['results']), self.model.objects.count())
self.assertEqual(sorted(response.data['results'][0]), self.brief_fields)
class CreateObjectViewTestCase(APITestCase):
create_data = []
def test_create_object(self):
"""
POST a single object.
"""
initial_count = self.model.objects.count()
url = self._get_list_url()
response = self.client.post(url, self.create_data[0], format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(self.model.objects.count(), initial_count + 1)
self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0], api=True)
def test_bulk_create_object(self):
"""
POST a set of objects in a single request.
"""
initial_count = self.model.objects.count()
url = self._get_list_url()
response = self.client.post(url, self.create_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data))
class UpdateObjectViewTestCase(APITestCase):
update_data = {}
def test_update_object(self):
"""
PATCH a single object identified by its numeric ID.
"""
instance = self.model.objects.first()
url = self._get_detail_url(instance)
update_data = self.update_data or getattr(self, 'create_data')[0]
response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db()
self.assertInstanceEqual(instance, self.update_data, api=True)
class DeleteObjectViewTestCase(APITestCase):
def test_delete_object(self):
"""
DELETE a single object identified by its numeric ID.
"""
instance = self.model.objects.first()
url = self._get_detail_url(instance)
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertFalse(self.model.objects.filter(pk=instance.pk).exists())
class APIViewTestCase(
GetObjectViewTestCase,
ListObjectsViewTestCase,
CreateObjectViewTestCase,
UpdateObjectViewTestCase,
DeleteObjectViewTestCase
):
pass

View File

@@ -213,9 +213,9 @@ def prepare_cloned_fields(instance):
if field_value not in (None, ''):
params[field_name] = field_value
# Copy tags
if is_taggable(instance):
params['tags'] = ','.join([t.name for t in instance.tags.all()])
# Copy tags
if is_taggable(instance):
params['tags'] = ','.join([t.name for t in instance.tags.all()])
# Concatenate parameters into a URL query string
param_string = '&'.join(

View File

@@ -1,31 +1,24 @@
import re
from django.conf import settings
from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator
class EnhancedURLValidator(URLValidator):
"""
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension.
Extends Django's built-in URLValidator to permit the use of hostnames with no domain extension and enforce allowed
schemes specified in the configuration.
"""
class AnyURLScheme(object):
"""
A fake URL list which "contains" all scheme names abiding by the syntax defined in RFC 3986 section 3.1
"""
def __contains__(self, item):
if not item or not re.match(r'^[a-z][0-9a-z+\-.]*$', item.lower()):
return False
return True
fqdn_re = URLValidator.hostname_re + URLValidator.domain_re + URLValidator.tld_re
host_res = [URLValidator.ipv4_re, URLValidator.ipv6_re, fqdn_re, URLValidator.hostname_re]
regex = _lazy_re_compile(
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (previously enforced by AnyURLScheme or schemes kwarg)
r'^(?:[a-z0-9\.\-\+]*)://' # Scheme (enforced separately)
r'(?:\S+(?::\S*)?@)?' # HTTP basic authentication
r'(?:' + '|'.join(host_res) + ')' # IPv4, IPv6, FQDN, or hostname
r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE)
schemes = AnyURLScheme()
schemes = settings.ALLOWED_URL_SCHEMES
class ExclusionValidator(BaseValidator):

View File

@@ -782,6 +782,8 @@ class BulkEditView(GetReturnURLMixin, View):
# TODO: Find a better way to accomplish this
if 'device' in request.GET:
initial_data['device'] = request.GET.get('device')
elif 'device_type' in request.GET:
initial_data['device_type'] = request.GET.get('device_type')
form = self.form(model, initial=initial_data)
@@ -948,6 +950,10 @@ class ComponentCreateView(GetReturnURLMixin, View):
))
if '_addanother' in request.POST:
return redirect(request.get_full_path())
elif 'device_type' in form.cleaned_data:
return redirect(form.cleaned_data['device_type'].get_absolute_url())
elif 'device' in form.cleaned_data:
return redirect(form.cleaned_data['device'].get_absolute_url())
else:
return redirect(self.get_return_url(request))

View File

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
CLUSTERTYPE_ACTIONS = """
@@ -28,10 +28,6 @@ VIRTUALMACHINE_STATUS = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
VIRTUALMACHINE_ROLE = """
{% if record.role %}<label class="label" style="background-color: #{{ record.role.color }}">{{ value }}</label>{% else %}&mdash;{% endif %}
"""
VIRTUALMACHINE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -132,9 +128,7 @@ class VirtualMachineTable(BaseTable):
viewname='virtualization:cluster',
args=[Accessor('cluster.pk')]
)
role = tables.TemplateColumn(
template_code=VIRTUALMACHINE_ROLE
)
role = ColoredLabelColumn()
tenant = tables.TemplateColumn(
template_code=COL_TENANT
)

View File

@@ -1,11 +1,10 @@
from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status
from dcim.choices import InterfaceModeChoices
from dcim.models import Interface
from ipam.models import IPAddress, VLAN
from utilities.testing import APITestCase, disable_warnings
from ipam.models import VLAN
from utilities.testing import APITestCase, APIViewTestCases
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -20,487 +19,181 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class ClusterTypeTest(APITestCase):
class ClusterTypeTest(APIViewTestCases.APIViewTestCase):
model = ClusterType
brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Cluster Type 4',
'slug': 'cluster-type-4',
},
{
'name': 'Cluster Type 5',
'slug': 'cluster-type-5',
},
{
'name': 'Cluster Type 6',
'slug': 'cluster-type-6',
},
]
def setUp(self):
@classmethod
def setUpTestData(cls):
super().setUp()
self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3')
def test_get_clustertype(self):
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.clustertype1.name)
def test_list_clustertypes(self):
url = reverse('virtualization-api:clustertype-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_clustertypes_brief(self):
url = reverse('virtualization-api:clustertype-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cluster_count', 'id', 'name', 'slug', 'url']
cluster_types = (
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
ClusterType(name='Cluster Type 3', slug='cluster-type-3'),
)
ClusterType.objects.bulk_create(cluster_types)
def test_create_clustertype(self):
data = {
'name': 'Test Cluster Type 4',
'slug': 'test-cluster-type-4',
}
class ClusterGroupTest(APIViewTestCases.APIViewTestCase):
model = ClusterGroup
brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Cluster Group 4',
'slug': 'cluster-type-4',
},
{
'name': 'Cluster Group 5',
'slug': 'cluster-type-5',
},
{
'name': 'Cluster Group 6',
'slug': 'cluster-type-6',
},
]
url = reverse('virtualization-api:clustertype-list')
response = self.client.post(url, data, format='json', **self.header)
@classmethod
def setUpTestData(cls):
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterType.objects.count(), 4)
clustertype4 = ClusterType.objects.get(pk=response.data['id'])
self.assertEqual(clustertype4.name, data['name'])
self.assertEqual(clustertype4.slug, data['slug'])
cluster_Groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-type-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-type-2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-type-3'),
)
ClusterGroup.objects.bulk_create(cluster_Groups)
def test_create_clustertype_bulk(self):
data = [
class ClusterTest(APIViewTestCases.APIViewTestCase):
model = Cluster
brief_fields = ['id', 'name', 'url', 'virtualmachine_count']
@classmethod
def setUpTestData(cls):
cluster_types = (
ClusterType(name='Cluster Type 1', slug='cluster-type-1'),
ClusterType(name='Cluster Type 2', slug='cluster-type-2'),
)
ClusterType.objects.bulk_create(cluster_types)
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
)
Cluster.objects.bulk_create(clusters)
cls.create_data = [
{
'name': 'Test Cluster Type 4',
'slug': 'test-cluster-type-4',
'name': 'Cluster 4',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
},
{
'name': 'Test Cluster Type 5',
'slug': 'test-cluster-type-5',
'name': 'Cluster 5',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
},
{
'name': 'Test Cluster Type 6',
'slug': 'test-cluster-type-6',
'name': 'Cluster 6',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
},
]
url = reverse('virtualization-api:clustertype-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterType.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'])
class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
model = VirtualMachine
brief_fields = ['id', 'name', 'url']
def test_update_clustertype(self):
@classmethod
def setUpTestData(cls):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
data = {
'name': 'Test Cluster Type X',
'slug': 'test-cluster-type-x',
}
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterType.objects.count(), 3)
clustertype1 = ClusterType.objects.get(pk=response.data['id'])
self.assertEqual(clustertype1.name, data['name'])
self.assertEqual(clustertype1.slug, data['slug'])
def test_delete_clustertype(self):
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ClusterType.objects.count(), 2)
class ClusterGroupTest(APITestCase):
def setUp(self):
super().setUp()
self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3')
def test_get_clustergroup(self):
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.clustergroup1.name)
def test_list_clustergroups(self):
url = reverse('virtualization-api:clustergroup-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_clustergroups_brief(self):
url = reverse('virtualization-api:clustergroup-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cluster_count', 'id', 'name', 'slug', 'url']
clusters = (
Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
)
Cluster.objects.bulk_create(clusters)
def test_create_clustergroup(self):
virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
)
VirtualMachine.objects.bulk_create(virtual_machines)
data = {
'name': 'Test Cluster Group 4',
'slug': 'test-cluster-group-4',
}
url = reverse('virtualization-api:clustergroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterGroup.objects.count(), 4)
clustergroup4 = ClusterGroup.objects.get(pk=response.data['id'])
self.assertEqual(clustergroup4.name, data['name'])
self.assertEqual(clustergroup4.slug, data['slug'])
def test_create_clustergroup_bulk(self):
data = [
cls.create_data = [
{
'name': 'Test Cluster Group 4',
'slug': 'test-cluster-group-4',
'name': 'Virtual Machine 4',
'cluster': clusters[1].pk,
},
{
'name': 'Test Cluster Group 5',
'slug': 'test-cluster-group-5',
'name': 'Virtual Machine 5',
'cluster': clusters[1].pk,
},
{
'name': 'Test Cluster Group 6',
'slug': 'test-cluster-group-6',
'name': 'Virtual Machine 6',
'cluster': clusters[1].pk,
},
]
url = reverse('virtualization-api:clustergroup-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ClusterGroup.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_clustergroup(self):
data = {
'name': 'Test Cluster Group X',
'slug': 'test-cluster-group-x',
}
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ClusterGroup.objects.count(), 3)
clustergroup1 = ClusterGroup.objects.get(pk=response.data['id'])
self.assertEqual(clustergroup1.name, data['name'])
self.assertEqual(clustergroup1.slug, data['slug'])
def test_delete_clustergroup(self):
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(ClusterGroup.objects.count(), 2)
class ClusterTest(APITestCase):
def setUp(self):
super().setUp()
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group)
self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group)
def test_get_cluster(self):
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.cluster1.name)
def test_list_clusters(self):
url = reverse('virtualization-api:cluster-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_clusters_brief(self):
url = reverse('virtualization-api:cluster-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'url', 'virtualmachine_count']
)
def test_create_cluster(self):
data = {
'name': 'Test Cluster 4',
'type': ClusterType.objects.first().pk,
'group': ClusterGroup.objects.first().pk,
}
url = reverse('virtualization-api:cluster-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cluster.objects.count(), 4)
cluster4 = Cluster.objects.get(pk=response.data['id'])
self.assertEqual(cluster4.name, data['name'])
self.assertEqual(cluster4.type.pk, data['type'])
self.assertEqual(cluster4.group.pk, data['group'])
def test_create_cluster_bulk(self):
data = [
{
'name': 'Test Cluster 4',
'type': ClusterType.objects.first().pk,
'group': ClusterGroup.objects.first().pk,
},
{
'name': 'Test Cluster 5',
'type': ClusterType.objects.first().pk,
'group': ClusterGroup.objects.first().pk,
},
{
'name': 'Test Cluster 6',
'type': ClusterType.objects.first().pk,
'group': ClusterGroup.objects.first().pk,
},
]
url = reverse('virtualization-api:cluster-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cluster.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_cluster(self):
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
data = {
'name': 'Test Cluster X',
'type': cluster_type2.pk,
'group': cluster_group2.pk,
}
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Cluster.objects.count(), 3)
cluster1 = Cluster.objects.get(pk=response.data['id'])
self.assertEqual(cluster1.name, data['name'])
self.assertEqual(cluster1.type.pk, data['type'])
self.assertEqual(cluster1.group.pk, data['group'])
def test_delete_cluster(self):
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Cluster.objects.count(), 2)
class VirtualMachineTest(APITestCase):
def setUp(self):
super().setUp()
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
self.virtualmachine_with_context_data = VirtualMachine.objects.create(
name='VM with context data',
cluster=self.cluster1,
local_context_data={
'A': 1,
'B': 2
}
)
def test_get_virtualmachine(self):
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.virtualmachine1.name)
def test_list_virtualmachines(self):
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 4)
def test_list_virtualmachines_brief(self):
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'url']
)
def test_create_virtualmachine(self):
data = {
'name': 'Test Virtual Machine 4',
'cluster': self.cluster1.pk,
}
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 5)
virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine4.name, data['name'])
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
def test_create_virtualmachine_without_cluster(self):
data = {
'name': 'Test Virtual Machine 4',
}
url = reverse('virtualization-api:virtualmachine-list')
with disable_warnings('django.request'):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
self.assertEqual(VirtualMachine.objects.count(), 4)
def test_create_virtualmachine_bulk(self):
data = [
{
'name': 'Test Virtual Machine 4',
'cluster': self.cluster1.pk,
},
{
'name': 'Test Virtual Machine 5',
'cluster': self.cluster1.pk,
},
{
'name': 'Test Virtual Machine 6',
'cluster': self.cluster1.pk,
},
]
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(VirtualMachine.objects.count(), 7)
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_virtualmachine(self):
interface = Interface.objects.create(name='Test Interface 1', virtual_machine=self.virtualmachine1)
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
cluster2 = Cluster.objects.create(
name='Test Cluster 2',
type=ClusterType.objects.first(),
group=ClusterGroup.objects.first()
)
data = {
'name': 'Test Virtual Machine X',
'cluster': cluster2.pk,
'primary_ip4': ip4_address.pk,
'primary_ip6': ip6_address.pk,
}
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(VirtualMachine.objects.count(), 4)
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine1.name, data['name'])
self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
self.assertEqual(virtualmachine1.primary_ip4.pk, data['primary_ip4'])
self.assertEqual(virtualmachine1.primary_ip6.pk, data['primary_ip6'])
def test_delete_virtualmachine(self):
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(VirtualMachine.objects.count(), 3)
def test_config_context_included_by_default_in_list_view(self):
"""
Check that config context data is included by default in the virtual machines list.
"""
virtualmachine = VirtualMachine.objects.first()
url = reverse('virtualization-api:virtualmachine-list')
url = '{}?id={}'.format(url, self.virtualmachine_with_context_data.pk)
url = '{}?id={}'.format(url, virtualmachine.pk)
response = self.client.get(url, **self.header)
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
def test_config_context_excluded(self):
"""
Check that config context data can be excluded by passing ?exclude=config_context.
"""
url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
self.assertFalse('config_context' in response.data['results'][0])
def test_unique_name_per_cluster_constraint(self):
"""
Check that creating a virtual machine with a duplicate name fails.
"""
data = {
'name': 'Test Virtual Machine 1',
'cluster': self.cluster1.pk,
'name': 'Virtual Machine 1',
'cluster': Cluster.objects.first().pk,
}
url = reverse('virtualization-api:virtualmachine-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
# TODO: Standardize InterfaceTest (pending #4721)
class InterfaceTest(APITestCase):
def setUp(self):

View File

@@ -187,14 +187,14 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = Interface
# Disable inapplicable tests
test_list_objects = None
test_import_objects = None
def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}'