mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-22 01:48:06 +01:00
Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce99929e2 | ||
|
|
22c482bdc3 | ||
|
|
c358097d52 | ||
|
|
26e37c1da6 | ||
|
|
fd564f09d1 | ||
|
|
76c2fd3414 | ||
|
|
712e850951 | ||
|
|
24cedab04b | ||
|
|
4262e2ef09 | ||
|
|
2dd494bc42 | ||
|
|
8faf586e14 | ||
|
|
08975b5ef9 | ||
|
|
9f363f493b | ||
|
|
2972993a84 | ||
|
|
9e1edd55d6 | ||
|
|
61ce8d1cb0 | ||
|
|
e2718973ce | ||
|
|
b081864e66 | ||
|
|
a912d6ed1e | ||
|
|
e45ebdffb1 | ||
|
|
5734c5e093 | ||
|
|
cb570790e6 | ||
|
|
bb4f21d5ee | ||
|
|
a262a8320b | ||
|
|
d39cda2e45 | ||
|
|
b69d2f1367 | ||
|
|
3fd3c7a383 | ||
|
|
8c4add38f4 | ||
|
|
d28cece264 | ||
|
|
a12d94a3bc | ||
|
|
9f4c1e64ce | ||
|
|
86956c8fc3 | ||
|
|
0991a8edaa | ||
|
|
f1e82a3647 | ||
|
|
357bf671ad | ||
|
|
183d475dc8 | ||
|
|
136d3118d2 | ||
|
|
2f5e623284 | ||
|
|
a7829a2deb | ||
|
|
9d243103f4 | ||
|
|
1f9a440598 | ||
|
|
1d0b27c99e | ||
|
|
48576919b2 | ||
|
|
0174983208 | ||
|
|
a7776d2f53 | ||
|
|
85254eb8b5 | ||
|
|
9078cb29cc | ||
|
|
0fd3c83861 | ||
|
|
087ad30d3c | ||
|
|
9c1dd159de | ||
|
|
bc7535c4d2 | ||
|
|
df20abf283 | ||
|
|
96c539c0ee | ||
|
|
ba8b99d3b8 | ||
|
|
cac48924ae | ||
|
|
7788bf3ce3 | ||
|
|
fa9ffb23ad | ||
|
|
a260019a7f | ||
|
|
683ba5eed3 | ||
|
|
d70140f148 | ||
|
|
fec3ee6f08 | ||
|
|
5700ade1a1 | ||
|
|
f807d3a024 | ||
|
|
20ee8ec107 | ||
|
|
e67f08c745 | ||
|
|
1c5af01a82 | ||
|
|
95462ce0ec | ||
|
|
9f614452b4 | ||
|
|
43d610405f | ||
|
|
7e8a4a2a77 | ||
|
|
56ec4a6360 | ||
|
|
0b1df1483f | ||
|
|
7defa22b0b | ||
|
|
52cff1ee50 | ||
|
|
8a26f475a7 | ||
|
|
51e9b0a22a | ||
|
|
268b4c854e | ||
|
|
c8461095c9 | ||
|
|
5dfa80c0b9 | ||
|
|
b26fc81187 | ||
|
|
0455947597 | ||
|
|
8179cfa4c1 | ||
|
|
d21881e207 | ||
|
|
25926e32f0 | ||
|
|
3fdc8e7d3d | ||
|
|
71afba4d2e | ||
|
|
ed1717f858 | ||
|
|
1cf0868e30 | ||
|
|
462f992a2b | ||
|
|
c5dc075fb0 | ||
|
|
0800279325 | ||
|
|
26770515e1 | ||
|
|
b0c24de596 | ||
|
|
715ddc6b02 | ||
|
|
e23a5ad141 | ||
|
|
3876efe494 | ||
|
|
f075339c5f | ||
|
|
abaf0daa6e | ||
|
|
4a11800d9e | ||
|
|
cafecb091d | ||
|
|
7cf0e6034b | ||
|
|
a5512dd4c4 | ||
|
|
bac3ace8fc | ||
|
|
60deb3f0ba | ||
|
|
eaaaaec5a5 | ||
|
|
5bcf85e57d | ||
|
|
1d466d6fd1 | ||
|
|
57cfb4ed7e | ||
|
|
9fa4cbdfa5 | ||
|
|
5af2b3c2f5 | ||
|
|
2e5058c4c9 | ||
|
|
9fc4a4f24a | ||
|
|
9fd36279ab | ||
|
|
40947f8cb2 | ||
|
|
9abc67bbeb | ||
|
|
16cdf3006f | ||
|
|
15004c654f | ||
|
|
062a319a7c | ||
|
|
ed9ca270a7 | ||
|
|
20ec700045 | ||
|
|
ecd3963b7c | ||
|
|
1ea368856b | ||
|
|
a8077e6ed1 | ||
|
|
7def37961a | ||
|
|
4f830c9c22 | ||
|
|
032f87caec | ||
|
|
e616aad911 | ||
|
|
c2f6f5a7cd | ||
|
|
d3fbaca228 | ||
|
|
ae913f14ce | ||
|
|
1ee79ee61e | ||
|
|
b5ebfd0b07 | ||
|
|
665646707c | ||
|
|
279ae7ea10 | ||
|
|
8cc1dc9f1c | ||
|
|
86e5a09b01 | ||
|
|
1d5f2fbd11 | ||
|
|
4219691e62 | ||
|
|
4ae1879b87 | ||
|
|
d2dce6db25 | ||
|
|
fae115b995 | ||
|
|
8f9dcf5a97 | ||
|
|
91ba44cc96 | ||
|
|
5330914431 | ||
|
|
927c012fc9 | ||
|
|
56f6698ba5 | ||
|
|
886b59f400 | ||
|
|
8bd9b460cb | ||
|
|
34ae57dfa3 | ||
|
|
81a322eaaf | ||
|
|
2479b8a57f | ||
|
|
2fe4656db4 | ||
|
|
6fc7c6a7d0 | ||
|
|
1d33d7d205 | ||
|
|
56898f7e37 | ||
|
|
3278cc8cc0 | ||
|
|
112dfb865b | ||
|
|
a0f4d481dc | ||
|
|
edf15532d2 | ||
|
|
d23b18beb5 | ||
|
|
56b7ab1734 | ||
|
|
68599351aa | ||
|
|
86755029ef | ||
|
|
c507ab30e9 | ||
|
|
7d1614b933 | ||
|
|
a77d1e502c | ||
|
|
d79ed76d80 | ||
|
|
ccf8059452 | ||
|
|
3d3d1bc623 | ||
|
|
e66d065b6d | ||
|
|
c1ef87e009 | ||
|
|
3c249a40a0 | ||
|
|
5092641157 | ||
|
|
472a45ddec | ||
|
|
120cbb0159 | ||
|
|
68fbd9b017 | ||
|
|
3143f75a38 | ||
|
|
e13d4ffe60 | ||
|
|
295d4f0394 | ||
|
|
ea91e09a1b | ||
|
|
946779000f | ||
|
|
5c07b6dc1d | ||
|
|
25c3c1b431 | ||
|
|
a0ae7a227d | ||
|
|
50df3acd26 | ||
|
|
425670f52a |
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -1 +1,5 @@
|
||||
*.sh text eol=lf
|
||||
# Treat minified or packed JS/CSS files as binary, as they're not meant to be human-readable
|
||||
*.min.* binary
|
||||
*.map binary
|
||||
*.pack.js binary
|
||||
|
||||
BIN
.github/images/netbox_triage_bug.png
vendored
Normal file
BIN
.github/images/netbox_triage_bug.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
.github/images/netbox_triage_feature.png
vendored
Normal file
BIN
.github/images/netbox_triage_feature.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
.github/images/netbox_triage_initial.png
vendored
Normal file
BIN
.github/images/netbox_triage_initial.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
8
.github/stale.yml
vendored
8
.github/stale.yml
vendored
@@ -4,19 +4,19 @@
|
||||
only: issues
|
||||
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
daysUntilStale: 45
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
daysUntilClose: 15
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- "status: accepted"
|
||||
- "status: gathering feedback"
|
||||
- "status: blocked"
|
||||
- "status: needs milestone"
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
staleLabel: "pending closure"
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@
|
||||
/netbox/static
|
||||
/venv/
|
||||
/*.sh
|
||||
local_requirements.txt
|
||||
!upgrade.sh
|
||||
fabfile.py
|
||||
gunicorn.py
|
||||
|
||||
@@ -99,6 +99,10 @@ help prevent wasting time on something that might we might not be able to
|
||||
implement. When suggesting a new feature, also make sure it won't conflict with
|
||||
any work that's already in progress.
|
||||
|
||||
* Once you've opened or identified an issue you'd like to work on, ask that it
|
||||
be assigned to you so that others are aware it's being worked on. A maintainer
|
||||
will then mark the issue as "accepted."
|
||||
|
||||
* Any pull request which does _not_ relate to an accepted issue will be closed.
|
||||
|
||||
* All major new functionality must include relevant tests where applicable.
|
||||
@@ -132,18 +136,17 @@ accumulating a large backlog of work.
|
||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
||||
to aid in issue management.
|
||||
|
||||
* Issues will be marked as stale after 14 days of no activity.
|
||||
* Then after 7 more days of inactivity, the issue will be closed.
|
||||
* Issues will be marked as stale after 45 days of no activity.
|
||||
* Then after 15 more days of inactivity, the issue will be closed.
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale
|
||||
bot actions:
|
||||
* `status: accepted`
|
||||
* `status: gathering feedback`
|
||||
* `status: blocked`
|
||||
* `status: needs milestone`
|
||||
|
||||
It is natural that some new issues get more attention than others. Often this
|
||||
is a metric of an issues's overall value to the project. In other cases in
|
||||
which issues merely get lost in the shuffle, notifications from Stale bot can
|
||||
bring renewed attention to potentially meaningful issues.
|
||||
It is natural that some new issues get more attention than others. Stale bot
|
||||
helps bring renewed attention to potentially valuable issues that may have been
|
||||
overlooked.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
@@ -279,6 +279,10 @@ http://localhost:8000/api/ipam/prefixes/ | jq ".actions.POST.status.choices"
|
||||
|
||||
For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
|
||||
|
||||
### Excluding Config Contexts
|
||||
|
||||
The rendered config context for devices and VMs is included by default in all API results (list and detail views). Users with large amounts of context data will most likely observe a performance drop when returning multiple objects, particularly with page sizes in the high hundreds or more. To combat this, in cases where the rendered config context is not needed, the query parameter `?exclude=config_context` may be appended to the request URL to exclude the config context data from the API response.
|
||||
|
||||
### Custom Fields
|
||||
|
||||
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -41,7 +41,14 @@ Create a file at `/docs/release-notes/X.Y.md` to establish the release notes for
|
||||
|
||||
### Manually Perform a New Install
|
||||
|
||||
Create a new installation of NetBox by following [the current documentation](http://netbox.readthedocs.io/en/latest/). This should be a manual process, so that issues with the documentation can be identified and corrected.
|
||||
Install `mkdocs` in your local environment, then start the documentation server:
|
||||
|
||||
```no-highlight
|
||||
$ pip install -r docs/requirements.txt
|
||||
$ mkdocs serve
|
||||
```
|
||||
|
||||
Follow these instructions to perform a new installation of NetBox. This process must _not_ be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release.
|
||||
|
||||
### Close the Release Milestone
|
||||
|
||||
|
||||
@@ -74,12 +74,18 @@ Checking connectivity... done.
|
||||
|
||||
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
|
||||
|
||||
!!! note
|
||||
CentOS users may need to create the `netbox` group first.
|
||||
#### Ubuntu
|
||||
|
||||
```
|
||||
# adduser --system --group netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
#### CentOS
|
||||
|
||||
```
|
||||
# groupadd --system netbox
|
||||
# adduser --system --gid netbox netbox
|
||||
# adduser --system -g netbox netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
|
||||
@@ -30,6 +30,12 @@ Copy the 'configuration.py' you created when first installing to the new version
|
||||
# cp netbox-X.Y.Z/netbox/netbox/configuration.py netbox/netbox/netbox/configuration.py
|
||||
```
|
||||
|
||||
Copy your local requirements file if used:
|
||||
|
||||
```no-highlight
|
||||
# cp netbox-X.Y.Z/local_requirements.txt netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
Also copy the LDAP configuration if using LDAP:
|
||||
|
||||
```no-highlight
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A power feed identifies the power outlet/drop that goes to a rack and is terminated to a power panel. Power feeds have a supply type (AC/DC), voltage, amperage, and phase type (single/three).
|
||||
|
||||
Power feeds are optionally assigned to a rack. In addition, a power port – and only one – can connect to a power feed; in the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
|
||||
Power feeds are optionally assigned to a rack. In addition, a power port may be connected to a power feed. In the context of a PDU, the power feed is analogous to the power outlet that a PDU's power port/inlet connects to.
|
||||
|
||||
!!! info
|
||||
The power usage of a rack is calculated when a power feed (or multiple) is assigned to that rack and connected to a power port.
|
||||
|
||||
3
docs/models/extras/imageattachment.md
Normal file
3
docs/models/extras/imageattachment.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Image Attachments
|
||||
|
||||
Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed.
|
||||
@@ -110,6 +110,8 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
|
||||
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
|
||||
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
|
||||
|
||||
All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
|
||||
|
||||
### Install the Plugin for Development
|
||||
|
||||
To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`):
|
||||
|
||||
@@ -1,5 +1,95 @@
|
||||
# NetBox v2.8
|
||||
|
||||
## v2.8.9 (2020-08-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4898](https://github.com/netbox-community/netbox/issues/4898) - Add MAC address search field to interfaces list
|
||||
* [#4899](https://github.com/netbox-community/netbox/issues/4899) - Add MAC address column to interfaces table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#4455](https://github.com/netbox-community/netbox/issues/4455) - Fix ordering of prefixes beneath aggregate when available space is hidden
|
||||
* [#4875](https://github.com/netbox-community/netbox/issues/4875) - Fix documentation for image attachments
|
||||
* [#4876](https://github.com/netbox-community/netbox/issues/4876) - Fix labels for sites in staging or decommissioning status
|
||||
* [#4880](https://github.com/netbox-community/netbox/issues/4880) - Fix removal of tagged VLANs if not assigned in bulk interface editing
|
||||
* [#4887](https://github.com/netbox-community/netbox/issues/4887) - Don't disable NAPALM tabs when device has no primary IP
|
||||
* [#4894](https://github.com/netbox-community/netbox/issues/4894) - Fix display of device/VM counts on platforms list
|
||||
* [#4895](https://github.com/netbox-community/netbox/issues/4895) - Force UTF-8 encoding when embedding model documentation
|
||||
* [#4910](https://github.com/netbox-community/netbox/issues/4910) - Unpin redis dependency to fix exception in RQ worker
|
||||
* [#4926](https://github.com/netbox-community/netbox/issues/4926) - Fix ordering of VM interfaces in REST API endpoint
|
||||
* [#4927](https://github.com/netbox-community/netbox/issues/4927) - Fix validation error when updating an existing secret
|
||||
* [#4929](https://github.com/netbox-community/netbox/issues/4929) - Correct log message when creating a new object
|
||||
|
||||
---
|
||||
|
||||
## v2.8.8 (2020-07-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#4805](https://github.com/netbox-community/netbox/issues/4805) - Improve handling of plugin loading errors
|
||||
* [#4829](https://github.com/netbox-community/netbox/issues/4829) - Add NEMA 15 power port and outlet types
|
||||
* [#4831](https://github.com/netbox-community/netbox/issues/4831) - Allow NAPALM to resolve device name when primary IP is not set
|
||||
* [#4854](https://github.com/netbox-community/netbox/issues/4854) - Add staging and decommissioning statuses for sites
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#3240](https://github.com/netbox-community/netbox/issues/3240) - Correct OpenAPI definition for available-prefixes endpoint
|
||||
* [#4595](https://github.com/netbox-community/netbox/issues/4595) - Ensure consistent display of non-racked and child devices on rack view
|
||||
* [#4803](https://github.com/netbox-community/netbox/issues/4803) - Return IP family (4 or 6) as integer rather than string
|
||||
* [#4821](https://github.com/netbox-community/netbox/issues/4821) - Restrict group options by selected site when bulk editing VLANs
|
||||
* [#4835](https://github.com/netbox-community/netbox/issues/4835) - Support passing multiple initial values for multiple choice fields
|
||||
* [#4838](https://github.com/netbox-community/netbox/issues/4838) - Fix rack power utilization display for racks without devices
|
||||
* [#4851](https://github.com/netbox-community/netbox/issues/4851) - Show locally connected peer on circuit terminations
|
||||
* [#4856](https://github.com/netbox-community/netbox/issues/4856) - Redirect user back to circuit after connecting a termination
|
||||
* [#4872](https://github.com/netbox-community/netbox/issues/4872) - Enable filtering virtual machine interfaces by tag
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
@@ -29,6 +30,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
|
||||
@@ -370,15 +372,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
Execute a NAPALM method on a Device
|
||||
"""
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
if not device.primary_ip:
|
||||
raise ServiceUnavailable("This device does not have a primary IP address configured.")
|
||||
if device.platform is None:
|
||||
raise ServiceUnavailable("No platform is configured for this device.")
|
||||
if not device.platform.napalm_driver:
|
||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format(
|
||||
raise ServiceUnavailable("No NAPALM driver is configured for this device's platform {}.".format(
|
||||
device.platform
|
||||
))
|
||||
|
||||
# Check for primary IP address from NetBox object
|
||||
if device.primary_ip:
|
||||
host = str(device.primary_ip.address.ip)
|
||||
else:
|
||||
# Raise exception for no IP address and no Name if device.name does not exist
|
||||
if not device.name:
|
||||
raise ServiceUnavailable(
|
||||
"This device does not have a primary IP address or device name to lookup configured.")
|
||||
try:
|
||||
# Attempt to complete a DNS name resolution if no primary_ip is set
|
||||
host = socket.gethostbyname(device.name)
|
||||
except socket.gaierror:
|
||||
# Name lookup failure
|
||||
raise ServiceUnavailable(
|
||||
f"Name lookup failure, unable to resolve IP address for {device.name}. Please set Primary IP or setup name resolution.")
|
||||
|
||||
# Check that NAPALM is installed
|
||||
try:
|
||||
import napalm
|
||||
@@ -398,10 +414,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
if not request.user.has_perm('dcim.napalm_read'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
# Connect to the device
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
response = OrderedDict([(m, None) for m in napalm_methods])
|
||||
ip_address = str(device.primary_ip.address.ip)
|
||||
username = settings.NAPALM_USERNAME
|
||||
password = settings.NAPALM_PASSWORD
|
||||
optional_args = settings.NAPALM_ARGS.copy()
|
||||
@@ -421,8 +435,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
elif key:
|
||||
optional_args[key.lower()] = request.headers[header]
|
||||
|
||||
# Connect to the device
|
||||
d = driver(
|
||||
hostname=ip_address,
|
||||
hostname=host,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=settings.NAPALM_TIMEOUT,
|
||||
@@ -431,7 +446,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
try:
|
||||
d.open()
|
||||
except Exception as e:
|
||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(ip_address, e))
|
||||
raise ServiceUnavailable("Error connecting to the device at {}: {}".format(host, e))
|
||||
|
||||
# Validate and execute each specified NAPALM method
|
||||
for method in napalm_methods:
|
||||
@@ -502,13 +517,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 +582,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
#
|
||||
|
||||
class CableViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
'termination_a', 'termination_b'
|
||||
)
|
||||
|
||||
@@ -7,13 +7,17 @@ from utilities.choices import ChoiceSet
|
||||
|
||||
class SiteStatusChoices(ChoiceSet):
|
||||
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_STAGING = 'staging'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
STATUS_RETIRED = 'retired'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_ACTIVE, 'Active'),
|
||||
(STATUS_PLANNED, 'Planned'),
|
||||
(STATUS_STAGING, 'Staging'),
|
||||
(STATUS_ACTIVE, 'Active'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
(STATUS_RETIRED, 'Retired'),
|
||||
)
|
||||
|
||||
@@ -260,6 +264,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 +273,36 @@ 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'
|
||||
TYPE_NEMA_1515P = 'nema-15-15p'
|
||||
TYPE_NEMA_1520P = 'nema-15-20p'
|
||||
TYPE_NEMA_1530P = 'nema-15-30p'
|
||||
TYPE_NEMA_1550P = 'nema-15-50p'
|
||||
TYPE_NEMA_1560P = 'nema-15-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_L1520P = 'nema-l15-20p'
|
||||
TYPE_NEMA_L1530P = 'nema-l15-30p'
|
||||
TYPE_NEMA_L1550P = 'nema-l15-50p'
|
||||
TYPE_NEMA_L1560P = 'nema-l15-60p'
|
||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||
# California style
|
||||
@@ -324,6 +349,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 +358,37 @@ 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'),
|
||||
(TYPE_NEMA_1515P, 'NEMA 15-15P'),
|
||||
(TYPE_NEMA_1520P, 'NEMA 15-20P'),
|
||||
(TYPE_NEMA_1530P, 'NEMA 15-30P'),
|
||||
(TYPE_NEMA_1550P, 'NEMA 15-50P'),
|
||||
(TYPE_NEMA_1560P, 'NEMA 15-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_L1520P, 'NEMA L15-20P'),
|
||||
(TYPE_NEMA_L1530P, 'NEMA L15-30P'),
|
||||
(TYPE_NEMA_L1550P, 'NEMA L15-50P'),
|
||||
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
|
||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||
)),
|
||||
@@ -397,6 +443,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 +452,36 @@ 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'
|
||||
TYPE_NEMA_1515R = 'nema-15-15r'
|
||||
TYPE_NEMA_1520R = 'nema-15-20r'
|
||||
TYPE_NEMA_1530R = 'nema-15-30r'
|
||||
TYPE_NEMA_1550R = 'nema-15-50r'
|
||||
TYPE_NEMA_1560R = 'nema-15-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_L1520R = 'nema-l15-20r'
|
||||
TYPE_NEMA_L1530R = 'nema-l15-30r'
|
||||
TYPE_NEMA_L1550R = 'nema-l15-50r'
|
||||
TYPE_NEMA_L1560R = 'nema-l15-60r'
|
||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||
# California style
|
||||
@@ -462,6 +529,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 +538,37 @@ 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'),
|
||||
(TYPE_NEMA_1515R, 'NEMA 15-15R'),
|
||||
(TYPE_NEMA_1520R, 'NEMA 15-20R'),
|
||||
(TYPE_NEMA_1530R, 'NEMA 15-30R'),
|
||||
(TYPE_NEMA_1550R, 'NEMA 15-50R'),
|
||||
(TYPE_NEMA_1560R, 'NEMA 15-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_L1520R, 'NEMA L15-20R'),
|
||||
(TYPE_NEMA_L1530R, 'NEMA L15-30R'),
|
||||
(TYPE_NEMA_L1550R, 'NEMA L15-50R'),
|
||||
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
|
||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||
)),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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,
|
||||
@@ -2686,6 +2671,10 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC address'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@@ -2756,13 +2745,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 +3022,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 +3196,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 +3292,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
|
||||
)
|
||||
|
||||
@@ -254,8 +254,10 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
]
|
||||
|
||||
STATUS_CLASS_MAP = {
|
||||
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
||||
SiteStatusChoices.STATUS_PLANNED: 'info',
|
||||
SiteStatusChoices.STATUS_STAGING: 'primary',
|
||||
SiteStatusChoices.STATUS_ACTIVE: 'success',
|
||||
SiteStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
SiteStatusChoices.STATUS_RETIRED: 'danger',
|
||||
}
|
||||
|
||||
@@ -731,8 +733,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
|
||||
@@ -787,7 +789,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
if power_stats:
|
||||
allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats)
|
||||
allocated_draw_total = sum(x['allocated_draw_total'] or 0 for x in power_stats)
|
||||
available_power_total = sum(x['available_power'] for x in power_stats)
|
||||
return int(allocated_draw_total / available_power_total * 100) or 0
|
||||
return 0
|
||||
@@ -2115,9 +2117,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 +2131,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 +2157,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 +2194,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."
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
RACK_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
|
||||
"""
|
||||
@@ -103,6 +94,14 @@ MANUFACTURER_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
DEVICEROLE_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
@@ -112,20 +111,12 @@ DEVICEROLE_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
DEVICEROLE_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_ACTIONS = """
|
||||
@@ -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>
|
||||
"""
|
||||
@@ -292,6 +278,7 @@ class RackGroupTable(BaseTable):
|
||||
|
||||
class RackRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
color = tables.TemplateColumn(COLOR_LABEL)
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -325,9 +312,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'
|
||||
@@ -721,20 +706,17 @@ class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
color = tables.TemplateColumn(
|
||||
template_code=COLOR_LABEL,
|
||||
verbose_name='Label'
|
||||
)
|
||||
vm_role = BooleanColumn()
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
@@ -755,14 +737,10 @@ class PlatformTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -806,8 +784,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(
|
||||
@@ -981,8 +958,8 @@ class InterfaceDetailTable(DeviceComponentDetailTable):
|
||||
|
||||
class Meta(InterfaceTable.Meta):
|
||||
order_by = ('parent', 'name')
|
||||
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'mac_address', 'description', 'cable')
|
||||
default_columns = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||
|
||||
|
||||
class FrontPortTable(BaseTable):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -23,7 +23,7 @@ from ipam.models import Prefix, VLAN
|
||||
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.utils import csv_format
|
||||
from utilities.utils import csv_format, get_subquery
|
||||
from utilities.views import (
|
||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
|
||||
ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
@@ -399,11 +399,12 @@ class RackView(PermissionRequiredMixin, View):
|
||||
|
||||
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
|
||||
|
||||
# Get 0U and child devices located within the rack
|
||||
nonracked_devices = Device.objects.filter(
|
||||
rack=rack,
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
position__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
if rack.group:
|
||||
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
|
||||
else:
|
||||
@@ -557,9 +558,9 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_manufacturer'
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=Count('device_types', distinct=True),
|
||||
inventoryitem_count=Count('inventory_items', distinct=True),
|
||||
platform_count=Count('platforms', distinct=True),
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
)
|
||||
table = tables.ManufacturerTable
|
||||
|
||||
@@ -1020,7 +1021,10 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_devicerole'
|
||||
queryset = DeviceRole.objects.all()
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
vm_count=get_subquery(VirtualMachine, 'role')
|
||||
)
|
||||
table = tables.DeviceRoleTable
|
||||
|
||||
|
||||
@@ -1055,7 +1059,10 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_platform'
|
||||
queryset = Platform.objects.all()
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
vm_count=get_subquery(VirtualMachine, 'platform')
|
||||
)
|
||||
table = tables.PlatformTable
|
||||
|
||||
|
||||
@@ -2057,7 +2064,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 +2073,7 @@ class CableTraceView(PermissionRequiredMixin, View):
|
||||
'obj': obj,
|
||||
'trace': path,
|
||||
'split_ends': split_ends,
|
||||
'position_stack': position_stack,
|
||||
'total_length': total_length,
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -16,6 +16,7 @@ from extras.models import (
|
||||
from extras.reports import get_report, get_reports
|
||||
from extras.scripts import get_script, get_scripts, run_script
|
||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||
from utilities.metadata import ContentTypeMetadata
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -88,6 +89,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class GraphViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = Graph.objects.all()
|
||||
serializer_class = serializers.GraphSerializer
|
||||
filterset_class = filters.GraphFilterSet
|
||||
@@ -98,6 +100,7 @@ class GraphViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class ExportTemplateViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ExportTemplate.objects.all()
|
||||
serializer_class = serializers.ExportTemplateSerializer
|
||||
filterset_class = filters.ExportTemplateFilterSet
|
||||
@@ -120,6 +123,7 @@ class TagViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class ImageAttachmentViewSet(ModelViewSet):
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
|
||||
@@ -271,6 +275,7 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of recent changes.
|
||||
"""
|
||||
metadata_class = ContentTypeMetadata
|
||||
queryset = ObjectChange.objects.prefetch_related('user')
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filters.ObjectChangeFilterSet
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,11 +6,12 @@ from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.template.loader import get_template
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from extras.registry import registry
|
||||
from utilities.choices import ButtonColorChoices
|
||||
|
||||
from extras.plugins.utils import import_object
|
||||
|
||||
|
||||
# Initialize plugin registry stores
|
||||
registry['plugin_template_extensions'] = collections.defaultdict(list)
|
||||
@@ -60,18 +61,14 @@ class PluginConfig(AppConfig):
|
||||
def ready(self):
|
||||
|
||||
# Register template content
|
||||
try:
|
||||
template_extensions = import_string(f"{self.__module__}.{self.template_extensions}")
|
||||
template_extensions = import_object(f"{self.__module__}.{self.template_extensions}")
|
||||
if template_extensions is not None:
|
||||
register_template_extensions(template_extensions)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register navigation menu items (if defined)
|
||||
try:
|
||||
menu_items = import_string(f"{self.__module__}.{self.menu_items}")
|
||||
menu_items = import_object(f"{self.__module__}.{self.menu_items}")
|
||||
if menu_items is not None:
|
||||
register_menu_items(self.verbose_name, menu_items)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def validate(cls, user_config):
|
||||
|
||||
@@ -3,7 +3,8 @@ from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.urls import path
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from extras.plugins.utils import import_object
|
||||
|
||||
from . import views
|
||||
|
||||
@@ -24,19 +25,15 @@ for plugin_path in settings.PLUGINS:
|
||||
base_url = getattr(app, 'base_url') or app.label
|
||||
|
||||
# Check if the plugin specifies any base URLs
|
||||
try:
|
||||
urlpatterns = import_string(f"{plugin_path}.urls.urlpatterns")
|
||||
urlpatterns = import_object(f"{plugin_path}.urls.urlpatterns")
|
||||
if urlpatterns is not None:
|
||||
plugin_patterns.append(
|
||||
path(f"{base_url}/", include((urlpatterns, app.label)))
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Check if the plugin specifies any API URLs
|
||||
try:
|
||||
urlpatterns = import_string(f"{plugin_path}.api.urls.urlpatterns")
|
||||
urlpatterns = import_object(f"{plugin_path}.api.urls.urlpatterns")
|
||||
if urlpatterns is not None:
|
||||
plugin_api_patterns.append(
|
||||
path(f"{base_url}/", include((urlpatterns, f"{app.label}-api")))
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
33
netbox/extras/plugins/utils.py
Normal file
33
netbox/extras/plugins/utils.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
|
||||
|
||||
def import_object(module_and_object):
|
||||
"""
|
||||
Import a specific object from a specific module by name, such as "extras.plugins.utils.import_object".
|
||||
|
||||
Returns the imported object, or None if it doesn't exist.
|
||||
"""
|
||||
target_module_name, object_name = module_and_object.rsplit('.', 1)
|
||||
module_hierarchy = target_module_name.split('.')
|
||||
|
||||
# Iterate through the module hierarchy, checking for the existence of each successive submodule.
|
||||
# We have to do this rather than jumping directly to calling find_spec(target_module_name)
|
||||
# because find_spec will raise a ModuleNotFoundError if any parent module of target_module_name does not exist.
|
||||
module_name = ""
|
||||
for module_component in module_hierarchy:
|
||||
module_name = f"{module_name}.{module_component}" if module_name else module_component
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
if spec is None:
|
||||
# No such module
|
||||
return None
|
||||
|
||||
# Okay, target_module_name exists. Load it if not already loaded
|
||||
if target_module_name in sys.modules:
|
||||
module = sys.modules[target_module_name]
|
||||
else:
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[target_module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
return getattr(module, object_name, None)
|
||||
@@ -4,13 +4,14 @@ from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.shortcuts import render
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.module_loading import import_string
|
||||
from django.views.generic import View
|
||||
from rest_framework import permissions
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from extras.plugins.utils import import_object
|
||||
|
||||
|
||||
class InstalledPluginsAdminView(View):
|
||||
"""
|
||||
@@ -60,9 +61,9 @@ class PluginsAPIRootView(APIView):
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_entry(plugin, app_config, request, format):
|
||||
try:
|
||||
api_app_name = import_string(f"{plugin}.api.urls.app_name")
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
# Check if the plugin specifies any API URLs
|
||||
api_app_name = import_object(f"{plugin}.api.urls.app_name")
|
||||
if api_app_name is None:
|
||||
# Plugin does not expose an API
|
||||
return None
|
||||
|
||||
@@ -73,7 +74,7 @@ class PluginsAPIRootView(APIView):
|
||||
format=format
|
||||
))
|
||||
except NoReverseMatch:
|
||||
# The plugin does not include an api-root
|
||||
# The plugin does not include an api-root url
|
||||
entry = None
|
||||
|
||||
return entry
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,15 +38,16 @@ class NestedRIRSerializer(WritableNestedSerializer):
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
model = models.RIR
|
||||
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
|
||||
|
||||
|
||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
model = models.Aggregate
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
@@ -59,7 +61,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 +70,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 +78,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']
|
||||
|
||||
|
||||
@@ -86,9 +88,10 @@ class NestedVLANSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
model = models.Prefix
|
||||
fields = ['id', 'url', 'family', 'prefix']
|
||||
|
||||
|
||||
@@ -96,10 +99,22 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
|
||||
class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
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']
|
||||
|
||||
@@ -74,12 +74,13 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
@swagger_auto_schema(
|
||||
methods=['get', 'post'],
|
||||
responses={
|
||||
200: serializers.AvailablePrefixSerializer(many=True),
|
||||
}
|
||||
)
|
||||
def get_serializer_class(self):
|
||||
if self.action == "available_prefixes" and self.request.method == "POST":
|
||||
return serializers.PrefixLengthSerializer
|
||||
return super().get_serializer_class()
|
||||
|
||||
@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 +95,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 +155,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 +174,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 +266,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
|
||||
|
||||
@@ -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(),
|
||||
@@ -1065,7 +1068,12 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'group': 'site_id'
|
||||
}
|
||||
)
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
|
||||
@@ -40,11 +40,11 @@ UTILIZATION_GRAPH = """
|
||||
"""
|
||||
|
||||
ROLE_PREFIX_COUNT = """
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
ROLE_VLAN_COUNT = """
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
ROLE_ACTIONS = """
|
||||
@@ -319,15 +319,11 @@ class AggregateDetailTable(AggregateTable):
|
||||
class RoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
prefix_count = tables.TemplateColumn(
|
||||
accessor=Accessor('prefixes.count'),
|
||||
template_code=ROLE_PREFIX_COUNT,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
)
|
||||
vlan_count = tables.TemplateColumn(
|
||||
accessor=Accessor('vlans.count'),
|
||||
template_code=ROLE_VLAN_COUNT,
|
||||
orderable=False,
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -524,7 +520,7 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
|
||||
class VLANGroupTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
name = tables.Column(linkify=True)
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ from django_tables2 import RequestConfig
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.views import (
|
||||
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
@@ -326,6 +327,8 @@ class AggregateView(PermissionRequiredMixin, View):
|
||||
prefix__net_contained_or_equal=str(aggregate.prefix)
|
||||
).prefetch_related(
|
||||
'site', 'role'
|
||||
).order_by(
|
||||
'prefix'
|
||||
).annotate_depth(
|
||||
limit=0
|
||||
)
|
||||
@@ -407,7 +410,10 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_role'
|
||||
queryset = Role.objects.all()
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
)
|
||||
table = tables.RoleTable
|
||||
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.8.5'
|
||||
VERSION = '2.8.9'
|
||||
|
||||
# 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', [])
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because one or more lines are too long
2
netbox/project-static/jquery/jquery-3.5.1.min.js
vendored
Normal file
2
netbox/project-static/jquery/jquery-3.5.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
).exclude(pk=self.instance.pk).exists():
|
||||
raise forms.ValidationError(
|
||||
"Each secret assigned to a device must have a unique combination of role and name"
|
||||
)
|
||||
|
||||
|
||||
class SecretCSVForm(CustomFieldModelCSVForm):
|
||||
device = CSVModelChoiceField(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="{% static 'jquery/jquery-3.4.1.min.js' %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=jquery/jquery-3.4.1.min.js'"></script>
|
||||
<script src="{% static 'jquery/jquery-3.5.1.min.js' %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=jquery/jquery-3.5.1.min.js'"></script>
|
||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"
|
||||
onerror="window.location='{% url 'media_failure' %}?filename=jquery-ui-1.12.1/jquery-ui.min.js'"></script>
|
||||
<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"
|
||||
|
||||
@@ -51,10 +51,15 @@
|
||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="fa fa-share-alt" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% if termination.connected_endpoint %}
|
||||
to <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
|
||||
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
|
||||
{% endif %}
|
||||
{% with peer=termination.get_cable_peer %}
|
||||
to
|
||||
{% if peer.device %}
|
||||
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
|
||||
{% elif peer.circuit %}
|
||||
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
|
||||
{% endif %}
|
||||
({{ peer }})
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="pull-right">
|
||||
@@ -63,10 +68,10 @@
|
||||
<span class="glyphicon glyphicon-resize-small" aria-hidden="true"></span> Connect
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-right">
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Interface</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ circuit.get_absolute_url }}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -108,8 +108,6 @@
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
|
||||
{% elif not device.platform.napalm_driver %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
|
||||
{% elif not device.primary_ip %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No primary IP address assigned to this device' %}
|
||||
{% else %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -341,7 +337,7 @@
|
||||
<th>Name</th>
|
||||
<th>Role</th>
|
||||
<th>Type</th>
|
||||
<th>Parent</th>
|
||||
<th colspan="2">Parent Device</th>
|
||||
</tr>
|
||||
{% for device in nonracked_devices %}
|
||||
<tr{% if device.device_type.u_height %} class="warning"{% endif %}>
|
||||
@@ -350,13 +346,12 @@
|
||||
</td>
|
||||
<td>{{ device.device_role }}</td>
|
||||
<td>{{ device.device_type.display_name }}</td>
|
||||
<td>
|
||||
{% if device.parent_bay %}
|
||||
<a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if device.parent_bay %}
|
||||
<td><a href="{{ device.parent_bay.device.get_absolute_url }}">{{ device.parent_bay.device }}</a></td>
|
||||
<td>{{ device.parent_bay }}</td>
|
||||
{% else %}
|
||||
<td colspan="2" class="text-muted">—</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -606,21 +594,20 @@ class DynamicModelChoiceMixin:
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
widget = APISelect
|
||||
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
return initial_data.get(field_name)
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
bound_field = BoundField(form, self, field_name)
|
||||
|
||||
# Override initial() to allow passing multiple values
|
||||
bound_field.initial = self._get_initial_value(form.initial, field_name)
|
||||
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
data = bound_field.value()
|
||||
if data:
|
||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
||||
self.queryset = filter.filter(self.queryset, data)
|
||||
field_name = getattr(self, 'to_field_name') or 'pk'
|
||||
filter = self.filter(field_name=field_name)
|
||||
try:
|
||||
self.queryset = filter.filter(self.queryset, data)
|
||||
except TypeError:
|
||||
# Catch any error caused by invalid initial data passed from the user
|
||||
self.queryset = self.queryset.none()
|
||||
else:
|
||||
self.queryset = self.queryset.none()
|
||||
|
||||
@@ -650,18 +637,11 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
||||
filter = django_filters.ModelMultipleChoiceFilter
|
||||
widget = APISelectMultiple
|
||||
|
||||
def _get_initial_value(self, initial_data, field_name):
|
||||
# If a QueryDict has been passed as initial form data, get *all* listed values
|
||||
if hasattr(initial_data, 'getlist'):
|
||||
return initial_data.getlist(field_name)
|
||||
return initial_data.get(field_name)
|
||||
|
||||
|
||||
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()]
|
||||
|
||||
|
||||
19
netbox/utilities/metadata.py
Normal file
19
netbox/utilities/metadata.py
Normal 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
|
||||
@@ -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 %}—{% 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.
|
||||
|
||||
@@ -27,12 +27,12 @@ def _get_viewname(instance, action):
|
||||
|
||||
@register.inclusion_tag('buttons/clone.html')
|
||||
def clone_button(instance):
|
||||
viewname = _get_viewname(instance, 'add')
|
||||
url = reverse(_get_viewname(instance, 'add'))
|
||||
|
||||
# Populate cloned field values
|
||||
param_string = prepare_cloned_fields(instance)
|
||||
if param_string:
|
||||
url = '{}?{}'.format(reverse(viewname), param_string)
|
||||
url = f'{url}?{param_string}'
|
||||
|
||||
return {
|
||||
'url': url,
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -166,7 +170,7 @@ def get_docs(model):
|
||||
model._meta.model_name
|
||||
)
|
||||
try:
|
||||
with open(path) as docfile:
|
||||
with open(path, encoding='utf-8') as docfile:
|
||||
content = docfile.read()
|
||||
except FileNotFoundError:
|
||||
return "Unable to load documentation, file not found: {}".format(path)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
from django.http import QueryDict
|
||||
from django.test import TestCase
|
||||
|
||||
from utilities.utils import deepmerge, dict_to_filter_params
|
||||
from utilities.utils import deepmerge, dict_to_filter_params, normalize_querydict
|
||||
|
||||
|
||||
class DictToFilterParamsTest(TestCase):
|
||||
"""
|
||||
Validate the operation of dict_to_filter_params().
|
||||
"""
|
||||
def setUp(self):
|
||||
return
|
||||
|
||||
def test_dict_to_filter_params(self):
|
||||
|
||||
input = {
|
||||
@@ -39,13 +37,21 @@ class DictToFilterParamsTest(TestCase):
|
||||
self.assertNotEqual(dict_to_filter_params(input), output)
|
||||
|
||||
|
||||
class NormalizeQueryDictTest(TestCase):
|
||||
"""
|
||||
Validate normalize_querydict() utility function.
|
||||
"""
|
||||
def test_normalize_querydict(self):
|
||||
self.assertDictEqual(
|
||||
normalize_querydict(QueryDict('foo=1&bar=2&bar=3&baz=')),
|
||||
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||
)
|
||||
|
||||
|
||||
class DeepMergeTest(TestCase):
|
||||
"""
|
||||
Validate the behavior of the deepmerge() utility.
|
||||
"""
|
||||
def setUp(self):
|
||||
return
|
||||
|
||||
def test_deepmerge(self):
|
||||
|
||||
dict1 = {
|
||||
|
||||
@@ -150,6 +150,24 @@ def dict_to_filter_params(d, prefix=''):
|
||||
return params
|
||||
|
||||
|
||||
def normalize_querydict(querydict):
|
||||
"""
|
||||
Convert a QueryDict to a normal, mutable dictionary, preserving list values. For example,
|
||||
|
||||
QueryDict('foo=1&bar=2&bar=3&baz=')
|
||||
|
||||
becomes:
|
||||
|
||||
{'foo': '1', 'bar': ['2', '3'], 'baz': ''}
|
||||
|
||||
This function is necessary because QueryDict does not provide any built-in mechanism which preserves multiple
|
||||
values.
|
||||
"""
|
||||
return {
|
||||
k: v if len(v) > 1 else v[0] for k, v in querydict.lists()
|
||||
}
|
||||
|
||||
|
||||
def deepmerge(original, new):
|
||||
"""
|
||||
Deep merge two dictionaries (new into original) and return a new dict
|
||||
@@ -213,9 +231,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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -27,7 +27,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
|
||||
from extras.querysets import CustomFieldQueryset
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
|
||||
from utilities.utils import csv_format, prepare_cloned_fields
|
||||
from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields
|
||||
from .error_handlers import handle_protectederror
|
||||
from .forms import ConfirmationForm, ImportForm
|
||||
from .paginator import EnhancedPaginator, get_paginate_count
|
||||
@@ -250,7 +250,7 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
# Parse initial data manually to avoid setting field values as lists
|
||||
initial_data = {k: request.GET[k] for k in request.GET}
|
||||
initial_data = normalize_querydict(request.GET)
|
||||
form = self.model_form(instance=self.obj, initial=initial_data)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
@@ -267,9 +267,10 @@ class ObjectEditView(GetReturnURLMixin, View):
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
object_created = form.instance.pk is None
|
||||
obj = form.save()
|
||||
msg = '{} {}'.format(
|
||||
'Created' if not form.instance.pk else 'Modified',
|
||||
'Created' if object_created else 'Modified',
|
||||
self.model._meta.verbose_name
|
||||
)
|
||||
logger.info(f"{msg} {obj} (PK: {obj.pk})")
|
||||
@@ -721,8 +722,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, ManyToManyField):
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
|
||||
if form.cleaned_data[name].count() > 0:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
@@ -782,6 +783,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 +951,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))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db.models import Count
|
||||
from dcim.models import Device, Interface
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from utilities.api import ModelViewSet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import get_subquery
|
||||
from virtualization import filters
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -74,7 +75,7 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.filter(
|
||||
virtual_machine__isnull=False
|
||||
).prefetch_related(
|
||||
).order_by('virtual_machine', CollateAsChar('_name')).prefetch_related(
|
||||
'virtual_machine', 'tags'
|
||||
)
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
|
||||
@@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
label='MAC address',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
|
||||
@@ -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,16 +28,20 @@ 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 %}—{% endif %}
|
||||
"""
|
||||
|
||||
VIRTUALMACHINE_PRIMARY_IP = """
|
||||
{{ record.primary_ip6.address.ip|default:"" }}
|
||||
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
|
||||
{{ record.primary_ip4.address.ip|default:"" }}
|
||||
"""
|
||||
|
||||
CLUSTER_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
CLUSTER_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?cluster_id={{ record.pk }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Cluster types
|
||||
@@ -98,14 +102,12 @@ class ClusterTable(BaseTable):
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')]
|
||||
)
|
||||
device_count = tables.Column(
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=CLUSTER_DEVICE_COUNT,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.Column(
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=CLUSTER_VM_COUNT,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
tags = TagColumn(
|
||||
@@ -132,9 +134,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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_{}'
|
||||
|
||||
@@ -10,6 +10,7 @@ from dcim.models import Device, Interface
|
||||
from dcim.tables import DeviceTable
|
||||
from extras.views import ObjectConfigContextView
|
||||
from ipam.models import Service
|
||||
from utilities.utils import get_subquery
|
||||
from utilities.views import (
|
||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
||||
ObjectEditView, ObjectListView,
|
||||
@@ -94,7 +95,10 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
|
||||
class ClusterListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'virtualization.view_cluster'
|
||||
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
|
||||
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant').annotate(
|
||||
device_count=get_subquery(Device, 'cluster'),
|
||||
vm_count=get_subquery(VirtualMachine, 'cluster')
|
||||
)
|
||||
table = tables.ClusterTable
|
||||
filterset = filters.ClusterFilterSet
|
||||
filterset_form = forms.ClusterFilterForm
|
||||
|
||||
@@ -21,5 +21,4 @@ Pillow==7.1.1
|
||||
psycopg2-binary==2.8.5
|
||||
pycryptodome==3.9.7
|
||||
PyYAML==5.3.1
|
||||
redis==3.4.1
|
||||
svgwrite==1.4
|
||||
|
||||
@@ -40,11 +40,12 @@ echo "Installing core dependencies ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
|
||||
# Install optional packages (if any)
|
||||
if [ -f "local_requirements.txt" ]
|
||||
then
|
||||
if [ -s "local_requirements.txt" ]; then
|
||||
COMMAND="pip3 install -r local_requirements.txt"
|
||||
echo "Installing local dependencies ($COMMAND)..."
|
||||
eval $COMMAND || exit 1
|
||||
elif [ -f "local_requirements.txt" ]; then
|
||||
echo "Skipping local dependencies (local_requirements.txt is empty)"
|
||||
else
|
||||
echo "Skipping local dependencies (local_requirements.txt not found)"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user