mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-07 17:39:32 +01:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e82a3647 | ||
|
|
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 | ||
|
|
886b59f400 | ||
|
|
8bd9b460cb | ||
|
|
34ae57dfa3 | ||
|
|
81a322eaaf | ||
|
|
2479b8a57f | ||
|
|
2fe4656db4 | ||
|
|
6fc7c6a7d0 | ||
|
|
1d33d7d205 | ||
|
|
56898f7e37 | ||
|
|
3278cc8cc0 | ||
|
|
112dfb865b | ||
|
|
a0f4d481dc |
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -382,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:
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# NetBox v2.8
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1026,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:
|
||||
@@ -1038,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()
|
||||
@@ -1078,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()
|
||||
@@ -1118,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
|
||||
@@ -1188,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
|
||||
@@ -1275,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()
|
||||
@@ -1335,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()
|
||||
@@ -1426,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(),
|
||||
@@ -1472,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
|
||||
@@ -2208,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(),
|
||||
@@ -2256,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,
|
||||
@@ -2342,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,
|
||||
@@ -2442,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,
|
||||
@@ -2551,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,
|
||||
@@ -2776,13 +2741,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
|
||||
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
|
||||
|
||||
|
||||
class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=InterfaceTypeChoices,
|
||||
widget=StaticSelect2(),
|
||||
@@ -3059,13 +3018,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
|
||||
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
|
||||
class FrontPortCreateForm(BootstrapMixin, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
class FrontPortCreateForm(ComponentCreateForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
widget=StaticSelect2(),
|
||||
@@ -3239,13 +3192,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class RearPortCreateForm(BootstrapMixin, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
class RearPortCreateForm(ComponentCreateForm):
|
||||
type = forms.ChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
widget=StaticSelect2(),
|
||||
@@ -3341,13 +3288,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayCreateForm(BootstrapMixin, forms.Form):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.prefetch_related('device_type__manufacturer')
|
||||
)
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name'
|
||||
)
|
||||
class DeviceBayCreateForm(ComponentCreateForm):
|
||||
tags = TagField(
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -731,8 +731,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
def get_elevation_svg(
|
||||
self,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
|
||||
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
|
||||
unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH,
|
||||
unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
include_images=True,
|
||||
base_url=None
|
||||
@@ -787,7 +787,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
|
||||
@@ -2129,6 +2129,7 @@ class Cable(ChangeLoggedModel):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
|
||||
def clean(self):
|
||||
from circuits.models import CircuitTermination
|
||||
|
||||
# Validate that termination A exists
|
||||
if not hasattr(self, 'termination_a_type'):
|
||||
@@ -2191,19 +2192,21 @@ class Cable(ChangeLoggedModel):
|
||||
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
|
||||
)
|
||||
|
||||
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
|
||||
# Check that a RearPort with multiple positions isn't connected to an endpoint
|
||||
# or a RearPort with a different number of positions.
|
||||
for term_a, term_b in [
|
||||
(self.termination_a, self.termination_b),
|
||||
(self.termination_b, self.termination_a)
|
||||
]:
|
||||
if isinstance(term_a, RearPort) and term_a.positions > 1:
|
||||
if not isinstance(term_b, RearPort):
|
||||
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
|
||||
raise ValidationError(
|
||||
"Rear ports with multiple positions may only be connected to other rear ports"
|
||||
"Rear ports with multiple positions may only be connected to other pass-through ports"
|
||||
)
|
||||
elif term_a.positions != term_b.positions:
|
||||
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
|
||||
raise ValidationError(
|
||||
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
|
||||
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
|
||||
f"{term_b} of {term_b.device} has {term_b.positions}. "
|
||||
f"Both terminations must have the same number of positions."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -103,20 +103,12 @@ DEVICEROLE_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
DEVICEROLE_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
|
||||
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 }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_DEVICE_COUNT = """
|
||||
<a href="{% url 'dcim:device_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
|
||||
VM_COUNT = """
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role={{ record.slug }}">{{ value|default:0 }}</a>
|
||||
"""
|
||||
|
||||
PLATFORM_ACTIONS = """
|
||||
@@ -278,6 +270,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(
|
||||
@@ -704,21 +697,18 @@ class DeviceBayTemplateTable(BaseTable):
|
||||
class DeviceRoleTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
template_code=DEVICE_COUNT,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
template_code=VM_COUNT,
|
||||
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'}},
|
||||
@@ -738,15 +728,11 @@ class DeviceRoleTable(BaseTable):
|
||||
class PlatformTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
device_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_DEVICE_COUNT,
|
||||
accessor=Accessor('devices.count'),
|
||||
orderable=False,
|
||||
template_code=DEVICE_COUNT,
|
||||
verbose_name='Devices'
|
||||
)
|
||||
vm_count = tables.TemplateColumn(
|
||||
template_code=PLATFORM_VM_COUNT,
|
||||
accessor=Accessor('virtual_machines.count'),
|
||||
orderable=False,
|
||||
template_code=VM_COUNT,
|
||||
verbose_name='VMs'
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, 'device_role'),
|
||||
vm_count=get_subquery(VirtualMachine, 'role')
|
||||
)
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class NestedRIRSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
@@ -87,6 +88,7 @@ class NestedVLANSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
@@ -99,6 +101,7 @@ class NestedPrefixSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
family = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.IPAddress
|
||||
|
||||
@@ -74,6 +74,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilterSet
|
||||
|
||||
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'])
|
||||
|
||||
@@ -1068,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')]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -407,7 +408,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
|
||||
|
||||
|
||||
|
||||
@@ -208,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.6'
|
||||
VERSION = '2.8.8'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -99,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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -49,212 +49,68 @@ class SecretRoleTest(APIViewTestCases.APIViewTestCase):
|
||||
SecretRole.objects.bulk_create(secret_roles)
|
||||
|
||||
|
||||
# TODO: Standardize SecretTest
|
||||
class SecretTest(APITestCase):
|
||||
class SecretTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Secret
|
||||
brief_fields = ['id', 'name', 'url']
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create a non-superuser test user
|
||||
self.user = create_test_user('testuser', permissions=(
|
||||
'secrets.add_secret',
|
||||
'secrets.change_secret',
|
||||
'secrets.delete_secret',
|
||||
'secrets.view_secret',
|
||||
))
|
||||
self.token = Token.objects.create(user=self.user)
|
||||
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -594,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()
|
||||
|
||||
@@ -638,12 +637,6 @@ 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):
|
||||
"""
|
||||
|
||||
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
|
||||
@@ -26,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
|
||||
#
|
||||
@@ -70,34 +118,7 @@ class TestCase(_TestCase):
|
||||
: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 = model_to_dict(instance, fields=data.keys())
|
||||
|
||||
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])
|
||||
model_dict = self.model_to_dict(instance, fields=data.keys(), api=api)
|
||||
|
||||
# Omit any dictionary keys which are not instance attributes
|
||||
relevant_data = {
|
||||
@@ -199,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(
|
||||
@@ -235,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(
|
||||
@@ -267,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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
@@ -950,6 +950,10 @@ class ComponentCreateView(GetReturnURLMixin, View):
|
||||
))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
elif 'device_type' in form.cleaned_data:
|
||||
return redirect(form.cleaned_data['device_type'].get_absolute_url())
|
||||
elif 'device' in form.cleaned_data:
|
||||
return redirect(form.cleaned_data['device'].get_absolute_url())
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ class InterfaceFilterSet(BaseFilterSet):
|
||||
mac_address = MultiValueMACAddressFilter(
|
||||
label='MAC address',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
|
||||
@@ -34,6 +34,14 @@ VIRTUALMACHINE_PRIMARY_IP = """
|
||||
{{ 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
|
||||
@@ -94,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user