Merge branch 'develop-2.9' into 2006-scripts-reports-background

This commit is contained in:
Jeremy Stretch 2020-07-02 11:00:59 -04:00 committed by GitHub
commit f98fa364c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
144 changed files with 4747 additions and 3590 deletions

View File

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

View File

@ -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`

View File

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

View File

@ -1,11 +1,20 @@
# NetBox v2.8
## v2.8.7 (FUTURE)
## 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
---

View File

@ -1,4 +1,4 @@
# NetBox v2.8
# NetBox v2.9
## v2.9.0 (FUTURE)
@ -10,9 +10,15 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
### Enhancements
* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates
* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports
* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates
### Configuration Changes
@ -33,10 +39,17 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
* A `label` field has been added to all device components and component templates.
* The IP address model now uses a generic foreign key to refer to the assigned interface. The `interface` field on the serializer has been replaced with `assigned_object_type` and `assigned_object_id` for write operations. If one exists, the assigned interface is available as `assigned_object`.
* The serialized representation of a virtual machine interface now includes only relevant fields: `type`, `lag`, `mgmt_only`, `connected_endpoint_type`, `connected_endpoint`, and `cable` are no longer included.
* dcim.VirtualChassis: Added a mandatory `name` field
* An optional `description` field has been added to all device component templates
### Other Changes
* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly.
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead).
* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`).
* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add` rather than via the devices list.
* A name is required when creating a virtual chassis.

View File

@ -1,4 +1,4 @@
from django.db.models import Count
from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
@ -28,8 +28,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular provider.
"""
provider = get_object_or_404(Provider, pk=pk)
queryset = Graph.objects.filter(type__model='provider')
provider = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
return Response(serializer.data)
@ -52,7 +52,10 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related(
'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
Prefetch('terminations', queryset=CircuitTermination.objects.unrestricted().prefetch_related(
'site', 'connected_endpoint__device'
)),
'type', 'tenant', 'provider',
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet

View File

@ -239,7 +239,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
return self.STATUS_CLASS_MAP.get(self.status)
def _get_termination(self, side):
for ct in self.terminations.all():
for ct in self.terminations.unrestricted():
if ct.term_side == side:
return ct
return None

View File

@ -10,7 +10,7 @@ def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
"""
circuits = Circuit.objects.filter(pk=instance.circuit_id)
circuits = Circuit.objects.unrestricted().filter(pk=instance.circuit_id)
time = timezone.now()
for circuit in circuits:
circuit.last_updated = time

View File

@ -2,19 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}"
class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable):
circuit_count = tables.Column(
verbose_name='Circuits'
)
actions = tables.TemplateColumn(
template_code=CIRCUITTYPE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(CircuitType, pk_field='slug')
class Meta(BaseTable.Meta):
model = CircuitType

View File

@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
"""
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
provider = self.model.objects.unrestricted().first()
ct = ContentType.objects.get(app_label='circuits', model='provider')
graphs = (
Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'),

View File

@ -25,6 +25,7 @@ urlpatterns = [
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<slug:slug>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits

View File

@ -60,19 +60,16 @@ class ProviderEditView(ObjectEditView):
queryset = Provider.objects.all()
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
class ProviderDeleteView(ObjectDeleteView):
queryset = Provider.objects.all()
default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(BulkEditView):
@ -80,14 +77,12 @@ class ProviderBulkEditView(BulkEditView):
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
#
@ -102,20 +97,21 @@ class CircuitTypeListView(ObjectListView):
class CircuitTypeEditView(ObjectEditView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
default_return_url = 'circuits:circuittype_list'
class CircuitTypeDeleteView(ObjectDeleteView):
queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
#
@ -165,19 +161,16 @@ class CircuitEditView(ObjectEditView):
queryset = Circuit.objects.all()
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
class CircuitDeleteView(ObjectDeleteView):
queryset = Circuit.objects.all()
default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(BulkEditView):
@ -185,14 +178,12 @@ class CircuitBulkEditView(BulkEditView):
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
form = forms.CircuitBulkEditForm
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(BulkDeleteView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'
class CircuitSwapTerminations(ObjectEditView):

View File

@ -332,7 +332,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
fields = ['id', 'url', 'master', 'member_count']
fields = ['id', 'name', 'url', 'master', 'member_count']
#

View File

@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
@ -183,10 +184,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
@ -245,7 +246,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'device_type', 'name', 'label', 'type']
fields = ['id', 'device_type', 'name', 'label', 'type', 'description']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@ -258,7 +259,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name', 'label', 'type']
fields = ['id', 'device_type', 'name', 'label', 'type', 'description']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
@ -271,7 +272,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw']
fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@ -292,7 +293,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg']
fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
@ -301,7 +302,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only']
fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description']
class RearPortTemplateSerializer(ValidatedModelSerializer):
@ -310,7 +311,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = RearPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'positions']
fields = ['id', 'device_type', 'name', 'type', 'positions', 'description']
class FrontPortTemplateSerializer(ValidatedModelSerializer):
@ -320,7 +321,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = FrontPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@ -328,7 +329,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = DeviceBayTemplate
fields = ['id', 'device_type', 'name', 'label']
fields = ['id', 'device_type', 'name', 'label', 'description']
#
@ -694,12 +695,12 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
#
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer()
master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain', 'tags', 'member_count']
fields = ['id', 'name', 'domain', 'master', 'tags', 'member_count']
#

View File

@ -29,6 +29,7 @@ from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, ModelViewSet, ServiceUnavailable,
)
from utilities.utils import get_subquery
from utilities.metadata import ContentTypeMetadata
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@ -43,7 +44,7 @@ class CableTraceMixin(object):
"""
Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination).
"""
obj = get_object_or_404(self.queryset.model, pk=pk)
obj = get_object_or_404(self.queryset, pk=pk)
# Initialize the path array
path = []
@ -103,8 +104,8 @@ class SiteViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular site.
"""
site = get_object_or_404(Site, pk=pk)
queryset = Graph.objects.filter(type__model='site')
site = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
@ -156,7 +157,7 @@ class RackViewSet(CustomFieldModelViewSet):
"""
Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG.
"""
rack = get_object_or_404(Rack, pk=pk)
rack = get_object_or_404(self.queryset, pk=pk)
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, 400)
@ -226,7 +227,7 @@ class ManufacturerViewSet(ModelViewSet):
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Count('instances')
)
serializer_class = serializers.DeviceTypeSerializer
@ -347,8 +348,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(Device, pk=pk)
queryset = Graph.objects.filter(type__model='device')
device = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@ -369,7 +370,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
Execute a NAPALM method on a Device
"""
device = get_object_or_404(Device, pk=pk)
device = get_object_or_404(self.queryset, pk=pk)
if not device.primary_ip:
raise ServiceUnavailable("This device does not have a primary IP address configured.")
if device.platform is None:
@ -496,8 +497,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
"""
A convenience method for rendering graphs for a particular interface.
"""
interface = get_object_or_404(Interface, pk=pk)
queryset = Graph.objects.filter(type__model='interface')
interface = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
return Response(serializer.data)
@ -567,6 +568,7 @@ class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
#
class CableViewSet(ModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
@ -655,7 +657,11 @@ class ConnectedDeviceViewSet(ViewSet):
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
peer_interface = get_object_or_404(
Interface.objects.unrestricted(),
device__name=peer_device_name,
name=peer_interface_name
)
local_interface = peer_interface._connected_interface
if local_interface is None:

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -79,42 +79,42 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,

View File

@ -75,37 +75,37 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,

View File

@ -43,17 +43,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='device',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,

View File

@ -35,12 +35,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='interface',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.6 on 2020-06-22 16:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0108_add_tags'),
('virtualization', '0016_replicate_interfaces'),
]
operations = [
migrations.RemoveField(
model_name='interface',
name='virtual_machine',
),
]

View File

@ -0,0 +1,46 @@
from django.db import migrations, models
import django.db.models.deletion
def copy_master_name(apps, schema_editor):
"""
Copy the master device's name to the VirtualChassis.
"""
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
for vc in VirtualChassis.objects.prefetch_related('master'):
name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}'
VirtualChassis.objects.filter(pk=vc.pk).update(name=name)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0109_interface_remove_vm'),
]
operations = [
migrations.AlterModelOptions(
name='virtualchassis',
options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'},
),
migrations.AddField(
model_name='virtualchassis',
name='name',
field=models.CharField(blank=True, max_length=64),
),
migrations.AlterField(
model_name='virtualchassis',
name='master',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
),
migrations.RunPython(
code=copy_master_name,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name='virtualchassis',
name='name',
field=models.CharField(max_length=64),
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 3.0.6 on 2020-06-30 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0110_virtualchassis_name'),
]
operations = [
migrations.AddField(
model_name='consoleporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='devicebaytemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='frontporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='interfacetemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='powerporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='rearporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
]

View File

@ -35,11 +35,12 @@ from .device_component_templates import (
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from .device_components import (
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
PowerPort, RearPort,
BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
PowerOutlet, PowerPort, RearPort,
)
__all__ = (
'BaseInterface',
'Cable',
'CableTermination',
'ConsolePort',
@ -579,7 +580,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
top_device = Device.objects.unrestricted().filter(
rack=self
).exclude(
position__isnull=True
).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height:
@ -600,13 +605,13 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
# Record the original site assignment for this rack.
_site_id = None
if self.pk:
_site_id = Rack.objects.get(pk=self.pk).site_id
_site_id = Rack.objects.unrestricted().get(pk=self.pk).site_id
super().save(*args, **kwargs)
# Update racked devices if the assigned Site has been changed.
if _site_id is not None and self.site_id != _site_id:
devices = Device.objects.filter(rack=self)
devices = Device.objects.unrestricted().filter(rack=self)
for device in devices:
device.site = self.site
device.save()
@ -668,7 +673,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
# Add devices to rack units list
if self.pk:
queryset = Device.objects.prefetch_related(
queryset = Device.objects.unrestricted().prefetch_related(
'device_type',
'device_type__manufacturer',
'device_role'
@ -744,8 +749,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
@ -1124,7 +1129,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# room to expand within their racks. This validation will impose a very high performance penalty when there are
# many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence.
if self.pk and self.u_height > self._original_u_height:
for d in Device.objects.filter(device_type=self, position__isnull=False):
for d in Device.objects.unrestricted().filter(device_type=self, position__isnull=False):
face_required = None if self.is_full_depth else d.face
u_available = d.rack.get_available_units(
u_height=self.u_height,
@ -1139,7 +1144,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
elif self.pk and self._original_u_height > 0 and self.u_height == 0:
racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count()
racked_instance_count = Device.objects.unrestricted().filter(
device_type=self,
position__isnull=False
).count()
if racked_instance_count:
url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
raise ValidationError({
@ -1492,7 +1500,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.name and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True):
if Device.objects.unrestricted().exclude(pk=self.pk).filter(
name=self.name,
site=self.site,
tenant__isnull=True
):
raise ValidationError({
'name': 'A device with this name already exists.'
})
@ -1571,9 +1583,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
raise ValidationError({
'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
})
if self.primary_ip4.interface in vc_interfaces:
if self.primary_ip4.assigned_object in vc_interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
@ -1584,9 +1596,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
raise ValidationError({
'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
})
if self.primary_ip6.interface in vc_interfaces:
if self.primary_ip6.assigned_object in vc_interfaces:
pass
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
@ -1622,32 +1634,32 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# If this is a new Device, instantiate all of the related components per the DeviceType definition
if is_new:
ConsolePort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
[x.instantiate(self) for x in self.device_type.consoleport_templates.unrestricted()]
)
ConsoleServerPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
[x.instantiate(self) for x in self.device_type.consoleserverport_templates.unrestricted()]
)
PowerPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.powerport_templates.all()]
[x.instantiate(self) for x in self.device_type.powerport_templates.unrestricted()]
)
PowerOutlet.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
[x.instantiate(self) for x in self.device_type.poweroutlet_templates.unrestricted()]
)
Interface.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.interface_templates.all()]
[x.instantiate(self) for x in self.device_type.interface_templates.unrestricted()]
)
RearPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.rearport_templates.all()]
[x.instantiate(self) for x in self.device_type.rearport_templates.unrestricted()]
)
FrontPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.frontport_templates.all()]
[x.instantiate(self) for x in self.device_type.frontport_templates.unrestricted()]
)
DeviceBay.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
[x.instantiate(self) for x in self.device_type.device_bay_templates.unrestricted()]
)
# Update Site and Rack assignment for any child Devices
devices = Device.objects.filter(parent_bay__device=self)
devices = Device.objects.unrestricted().filter(parent_bay__device=self)
for device in devices:
device.site = self.site
device.rack = self.rack
@ -1738,7 +1750,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
"""
Return the set of child Devices installed in DeviceBays within this Device.
"""
return Device.objects.filter(parent_bay__device=self.pk)
return Device.objects.unrestricted().filter(parent_bay__device=self.pk)
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)
@ -1756,7 +1768,12 @@ class VirtualChassis(ChangeLoggedModel):
master = models.OneToOneField(
to='Device',
on_delete=models.PROTECT,
related_name='vc_master_for'
related_name='vc_master_for',
blank=True,
null=True
)
name = models.CharField(
max_length=64
)
domain = models.CharField(
max_length=30,
@ -1766,14 +1783,14 @@ class VirtualChassis(ChangeLoggedModel):
objects = RestrictedQuerySet.as_manager()
csv_headers = ['master', 'domain']
csv_headers = ['name', 'domain', 'master']
class Meta:
ordering = ['master']
ordering = ['name']
verbose_name_plural = 'virtual chassis'
def __str__(self):
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
return self.name
def get_absolute_url(self):
return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
@ -1782,15 +1799,15 @@ class VirtualChassis(ChangeLoggedModel):
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
# VirtualChassis.)
if self.pk and self.master not in self.members.all():
if self.pk and self.master and self.master not in self.members.all():
raise ValidationError({
'master': "The selected master is not assigned to this virtual chassis."
'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
})
def delete(self, *args, **kwargs):
# Check for LAG interfaces split across member chassis
interfaces = Interface.objects.filter(
interfaces = Interface.objects.unrestricted().filter(
device__in=self.members.all(),
lag__isnull=False
).exclude(
@ -1798,8 +1815,7 @@ class VirtualChassis(ChangeLoggedModel):
)
if interfaces:
raise ProtectedError(
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
"LAG".format(self),
f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
interfaces
)
@ -1807,8 +1823,9 @@ class VirtualChassis(ChangeLoggedModel):
def to_csv(self):
return (
self.master,
self.name,
self.domain,
self.master.name if self.master else None,
)
@ -2158,12 +2175,13 @@ 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'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
self.termination_a_type.model_class().objects.unrestricted().get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
@ -2173,7 +2191,7 @@ class Cable(ChangeLoggedModel):
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
self.termination_b_type.model_class().objects.unrestricted().get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
@ -2220,19 +2238,21 @@ class Cable(ChangeLoggedModel):
f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
)
# A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
# Check that a RearPort with multiple positions isn't connected to an endpoint
# or a RearPort with a different number of positions.
for term_a, term_b in [
(self.termination_a, self.termination_b),
(self.termination_b, self.termination_a)
]:
if isinstance(term_a, RearPort) and term_a.positions > 1:
if not isinstance(term_b, RearPort):
if not isinstance(term_b, (FrontPort, RearPort, CircuitTermination)):
raise ValidationError(
"Rear ports with multiple positions may only be connected to other rear ports"
"Rear ports with multiple positions may only be connected to other pass-through ports"
)
elif term_a.positions != term_b.positions:
if isinstance(term_b, RearPort) and term_b.positions > 1 and term_a.positions != term_b.positions:
raise ValidationError(
f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
f"{term_a} of {term_a.device} has {term_a.positions} position(s) but "
f"{term_b} of {term_b.device} has {term_b.positions}. "
f"Both terminations must have the same number of positions."
)

View File

@ -27,6 +27,11 @@ __all__ = (
class ComponentTemplateModel(models.Model):
description = models.CharField(
max_length=200,
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:

View File

@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
__all__ = (
@ -53,18 +52,12 @@ class ComponentModel(models.Model):
return self.name
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
except ObjectDoesNotExist:
# The parent device/VM has already been deleted
parent = None
# Annotate the parent Device
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent,
related_object=self.device,
object_data=serialize_object(self)
)
@ -94,16 +87,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:
@ -123,26 +116,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(
@ -173,12 +175,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()
@ -191,10 +193,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:
@ -211,7 +213,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:
@ -275,7 +277,7 @@ class ConsolePort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
def to_csv(self):
return (
@ -332,7 +334,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
def to_csv(self):
return (
@ -415,7 +417,7 @@ class PowerPort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:powerport', kwargs={'pk': self.pk})
def to_csv(self):
return (
@ -567,7 +569,7 @@ class PowerOutlet(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
def to_csv(self):
return (
@ -592,26 +594,7 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces
#
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel):
"""
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
Interface.
"""
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
class BaseInterface(models.Model):
name = models.CharField(
max_length=64
)
@ -621,6 +604,42 @@ class Interface(CableTermination, ComponentModel):
max_length=100,
blank=True
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
class Meta:
abstract = True
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel, BaseInterface):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
label = models.CharField(
max_length=64,
blank=True,
@ -656,30 +675,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50,
choices=InterfaceTypeChoices
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@ -694,15 +694,19 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='interface'
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
'description', 'mode',
'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
]
class Meta:
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
@ -712,7 +716,6 @@ class Interface(CableTermination, ComponentModel):
def to_csv(self):
return (
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name,
self.lag.name if self.lag else None,
self.get_type_display(),
@ -726,18 +729,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
raise ValidationError({
'type': "Invalid interface type for a virtual machine: {}".format(self.type)
})
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False)
@ -773,7 +764,7 @@ class Interface(CableTermination, ComponentModel):
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
"device/VM, or it must be global".format(self.untagged_vlan)
"device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
@ -788,21 +779,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
parent_obj = self.device or self.virtual_machine
except ObjectDoesNotExist:
parent_obj = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
object_data=serialize_object(self)
)
@property
def connected_endpoint(self):
"""
@ -841,7 +817,7 @@ class Interface(CableTermination, ComponentModel):
@property
def parent(self):
return self.device or self.virtual_machine
return self.device
@property
def is_connectable(self):
@ -902,7 +878,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')
@ -914,6 +889,9 @@ class FrontPort(CableTermination, ComponentModel):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:frontport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
@ -970,7 +948,6 @@ class RearPort(CableTermination, ComponentModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
is_path_endpoint = False
class Meta:
ordering = ('device', '_name')
@ -979,6 +956,9 @@ class RearPort(CableTermination, ComponentModel):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
@ -1038,7 +1018,7 @@ class DeviceBay(ComponentModel):
return '{} - {}'.format(self.device.name, self.name)
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
def to_csv(self):
return (
@ -1147,7 +1127,7 @@ class InventoryItem(ComponentModel):
return self.name
def get_absolute_url(self):
return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
def to_csv(self):
return (

View File

@ -4,20 +4,19 @@ 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)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
When a VirtualChassis is created, automatically assign its master device to the VC.
When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
"""
if created:
devices = Device.objects.filter(pk=instance.master.pk)
for device in devices:
device.virtual_chassis = instance
device.vc_position = None
device.save()
if created and instance.master:
master = Device.objects.get(pk=instance.master.pk)
master.virtual_chassis = instance
master.vc_position = 1
master.save()
@receiver(pre_delete, sender=VirtualChassis)
@ -52,7 +51,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 +60,11 @@ def update_connected_endpoints(instance, **kwargs):
break
endpoint_a = path[0][0]
endpoint_b = path[-1][2]
endpoint_b = path[-1][2] if not split_ends and not position_stack else None
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
# Patch panel ports are not connected endpoints, all other cable terminations are
if isinstance(endpoint_a, CableTermination) and not isinstance(endpoint_a, (FrontPort, RearPort)) and \
isinstance(endpoint_b, CableTermination) and not isinstance(endpoint_b, (FrontPort, RearPort)):
logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b))
endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status

View File

@ -2,7 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn
from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ColoredLabelColumn, TagColumn, ToggleColumn,
)
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
@ -40,69 +42,16 @@ DEVICE_LINK = """
</a>
"""
REGION_ACTIONS = """
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACKGROUP_ACTIONS = """
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
RACKGROUP_ELEVATIONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i>
</a>
{% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning" title="Edit">
<i class="glyphicon glyphicon-pencil"></i>
</a>
{% endif %}
"""
RACKROLE_ACTIONS = """
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACK_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?rack_id={{ record.pk }}">{{ value }}</a>
"""
RACKRESERVATION_ACTIONS = """
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
MANUFACTURER_ACTIONS = """
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_ACTIONS = """
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
DEVICEROLE_DEVICE_COUNT = """
<a href="{% url 'dcim:device_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
@ -119,15 +68,6 @@ PLATFORM_VM_COUNT = """
<a href="{% url 'virtualization:virtualmachine_list' %}?platform={{ record.slug }}">{{ value }}</a>
"""
PLATFORM_ACTIONS = """
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@ -198,11 +138,7 @@ class RegionTable(BaseTable):
site_count = tables.Column(
verbose_name='Sites'
)
actions = tables.TemplateColumn(
template_code=REGION_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta):
model = Region
@ -260,10 +196,9 @@ class RackGroupTable(BaseTable):
rack_count = tables.Column(
verbose_name='Racks'
)
actions = tables.TemplateColumn(
template_code=RACKGROUP_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
actions = ButtonsColumn(
model=RackGroup,
prepend_template=RACKGROUP_ELEVATIONS
)
class Meta(BaseTable.Meta):
@ -280,11 +215,7 @@ class RackRoleTable(BaseTable):
pk = ToggleColumn()
rack_count = tables.Column(verbose_name='Racks')
color = tables.TemplateColumn(COLOR_LABEL)
actions = tables.TemplateColumn(
template_code=RACKROLE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta):
model = RackRole
@ -386,11 +317,7 @@ class RackReservationTable(BaseTable):
tags = TagColumn(
url_name='dcim:rackreservation_list'
)
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta):
model = RackReservation
@ -420,11 +347,7 @@ class ManufacturerTable(BaseTable):
verbose_name='Platforms'
)
slug = tables.Column()
actions = tables.TemplateColumn(
template_code=MANUFACTURER_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(Manufacturer, pk_field='slug')
class Meta(BaseTable.Meta):
model = Manufacturer
@ -486,22 +409,10 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = ConsolePortTemplate
fields = ('pk', 'name', 'label', 'type', 'actions')
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
class ConsolePortImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = ConsolePort
fields = ('device', 'name', 'description')
empty_text = False
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn(
template_code=get_component_template_actions('consoleserverporttemplate'),
@ -511,22 +422,10 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = ConsoleServerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'actions')
fields = ('pk', 'name', 'label', 'type', 'description', 'actions')
empty_text = "None"
class ConsoleServerPortImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = ConsoleServerPort
fields = ('device', 'name', 'description')
empty_text = False
class PowerPortTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn(
template_code=get_component_template_actions('powerporttemplate'),
@ -536,22 +435,10 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'actions')
fields = ('pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'actions')
empty_text = "None"
class PowerPortImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('device', 'name', 'description', 'maximum_draw', 'allocated_draw')
empty_text = False
class PowerOutletTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn(
template_code=get_component_template_actions('poweroutlettemplate'),
@ -561,22 +448,10 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'actions')
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"
class PowerOutletImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = PowerOutlet
fields = ('device', 'name', 'description', 'power_port', 'feed_leg')
empty_text = False
class InterfaceTemplateTable(ComponentTemplateTable):
mgmt_only = BooleanColumn(
verbose_name='Management Only'
@ -589,30 +464,10 @@ class InterfaceTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'actions')
fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions')
empty_text = "None"
class InterfaceImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
virtual_machine = tables.LinkColumn(
viewname='virtualization:virtualmachine',
args=[Accessor('virtual_machine.pk')],
verbose_name='Virtual Machine'
)
class Meta(BaseTable.Meta):
model = Interface
fields = (
'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu',
'mgmt_only', 'mode',
)
empty_text = False
class FrontPortTemplateTable(ComponentTemplateTable):
rear_port_position = tables.Column(
verbose_name='Position'
@ -625,22 +480,10 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = FrontPortTemplate
fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'actions')
fields = ('pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'actions')
empty_text = "None"
class FrontPortImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = FrontPort
fields = ('device', 'name', 'description', 'type', 'rear_port', 'rear_port_position')
empty_text = False
class RearPortTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn(
template_code=get_component_template_actions('rearporttemplate'),
@ -650,22 +493,10 @@ class RearPortTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = RearPortTemplate
fields = ('pk', 'name', 'label', 'type', 'positions', 'actions')
fields = ('pk', 'name', 'label', 'type', 'positions', 'description', 'actions')
empty_text = "None"
class RearPortImportTable(BaseTable):
device = tables.LinkColumn(
viewname='dcim:device',
args=[Accessor('device.pk')]
)
class Meta(BaseTable.Meta):
model = RearPort
fields = ('device', 'name', 'description', 'type', 'position')
empty_text = False
class DeviceBayTemplateTable(ComponentTemplateTable):
actions = tables.TemplateColumn(
template_code=get_component_template_actions('devicebaytemplate'),
@ -675,7 +506,7 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
class Meta(BaseTable.Meta):
model = DeviceBayTemplate
fields = ('pk', 'name', 'label', 'actions')
fields = ('pk', 'name', 'label', 'description', 'actions')
empty_text = "None"
@ -701,11 +532,8 @@ class DeviceRoleTable(BaseTable):
template_code=COLOR_LABEL,
verbose_name='Label'
)
actions = tables.TemplateColumn(
template_code=DEVICEROLE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
vm_role = BooleanColumn()
actions = ButtonsColumn(DeviceRole, pk_field='slug')
class Meta(BaseTable.Meta):
model = DeviceRole
@ -731,11 +559,7 @@ class PlatformTable(BaseTable):
orderable=False,
verbose_name='VMs'
)
actions = tables.TemplateColumn(
template_code=PLATFORM_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(Platform, pk_field='slug')
class Meta(BaseTable.Meta):
model = Platform
@ -861,153 +685,108 @@ class DeviceImportTable(BaseTable):
# Device components
#
class DeviceComponentDetailTable(BaseTable):
class DeviceComponentTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(order_by=('_name',))
cable = tables.LinkColumn()
device = tables.Column(
linkify=True
)
name = tables.Column(
linkify=True,
order_by=('_name',)
)
cable = tables.Column(
linkify=True
)
class Meta(BaseTable.Meta):
order_by = ('device', 'name')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
sequence = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
class ConsolePortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class ConsolePortTable(DeviceComponentTable):
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = ('name', 'label', 'type')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class ConsolePortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class ConsoleServerPortTable(DeviceComponentTable):
class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
pass
class ConsoleServerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = ('name', 'label', 'description')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class PowerPortTable(DeviceComponentTable):
class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
pass
class PowerPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = ('name', 'label', 'type')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
class PowerPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class PowerOutletTable(DeviceComponentTable):
class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
pass
class PowerOutletTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = ('name', 'label', 'type', 'description')
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
class PowerOutletDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
pass
class InterfaceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('name', 'label', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
class InterfaceDetailTable(DeviceComponentDetailTable):
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
name = tables.LinkColumn()
class InterfaceTable(DeviceComponentTable):
enabled = BooleanColumn()
class Meta(InterfaceTable.Meta):
order_by = ('parent', 'name')
fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'description', 'cable',
)
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
class FrontPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class FrontPortTable(DeviceComponentTable):
rear_port_position = tables.Column(
verbose_name='Position'
)
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = ('name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
empty_text = "None"
fields = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
class FrontPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class RearPortTable(DeviceComponentTable):
class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
pass
class RearPortTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = ('name', 'label', 'type', 'positions', 'description')
empty_text = "None"
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable')
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
class RearPortDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
class DeviceBayTable(DeviceComponentTable):
installed_device = tables.Column(
linkify=True
)
class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
pass
class DeviceBayTable(BaseTable):
name = tables.Column(order_by=('_name',))
class Meta(BaseTable.Meta):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('name', 'label', 'description')
class DeviceBayDetailTable(DeviceComponentDetailTable):
device = tables.LinkColumn()
installed_device = tables.LinkColumn()
class Meta(DeviceBayTable.Meta):
fields = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
sequence = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
exclude = ('cable',)
default_columns = ('pk', 'device', 'name', 'label', 'installed_device', 'description')
class DeviceBayImportTable(BaseTable):
device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device')
installed_device = tables.LinkColumn('dcim:device', args=[Accessor('installed_device.pk')], verbose_name='Installed Device')
class InventoryItemTable(DeviceComponentTable):
manufacturer = tables.Column(
linkify=True
)
discovered = BooleanColumn()
class Meta(BaseTable.Meta):
model = DeviceBay
fields = ('device', 'name', 'installed_device', 'description')
empty_text = False
class Meta(DeviceComponentTable.Meta):
model = InventoryItem
fields = (
'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered'
)
default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag')
#
@ -1152,29 +931,6 @@ class InterfaceConnectionTable(BaseTable):
)
#
# InventoryItems
#
class InventoryItemTable(BaseTable):
pk = ToggleColumn()
device = tables.LinkColumn(
viewname='dcim:device_inventory',
args=[Accessor('device.pk')]
)
manufacturer = tables.Column(
accessor=Accessor('manufacturer')
)
discovered = BooleanColumn()
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
'pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered'
)
default_columns = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag')
#
# Virtual chassis
#
@ -1182,7 +938,9 @@ class InventoryItemTable(BaseTable):
class VirtualChassisTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
accessor=Accessor('master__name'),
linkify=True
)
master = tables.Column(
linkify=True
)
member_count = tables.Column(
@ -1194,8 +952,8 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'name', 'domain', 'member_count', 'tags')
default_columns = ('pk', 'name', 'domain', 'member_count')
fields = ('pk', 'name', 'domain', 'master', 'member_count', 'tags')
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
#

View File

@ -28,6 +28,43 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class Mixins:
class ComponentTraceMixin(APITestCase):
peer_termination_type = None
def test_trace(self):
"""
Test tracing a device component's attached cable.
"""
obj = self.model.objects.unrestricted().first()
peer_device = Device.objects.create(
site=Site.objects.unrestricted().first(),
device_type=DeviceType.objects.unrestricted().first(),
device_role=DeviceRole.objects.unrestricted().first(),
name='Peer Device'
)
if self.peer_termination_type is None:
raise NotImplementedError("Test case must set peer_termination_type")
peer_obj = self.peer_termination_type.objects.create(
device=peer_device,
name='Peer Termination'
)
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], obj.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], peer_obj.name)
class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region
brief_fields = ['id', 'name', 'site_count', 'slug', 'url']
@ -107,7 +144,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_site')
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.unrestricted().unrestricted().first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
@ -246,7 +283,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
"""
GET a single rack elevation.
"""
rack = Rack.objects.first()
rack = Rack.objects.unrestricted().first()
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
@ -266,7 +303,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
"""
GET a single rack elevation in SVG format.
"""
rack = Rack.objects.first()
rack = Rack.objects.unrestricted().first()
self.add_permissions('dcim.view_rack')
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
@ -281,9 +318,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
user = User.objects.create(username='user1', is_active=True)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
cls.racks = (
@ -878,7 +913,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.unrestricted().first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
@ -908,7 +943,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
"""
Check that creating a device with a duplicate name within a site fails.
"""
device = Device.objects.first()
device = Device.objects.unrestricted().first()
data = {
'device_type': device.device_type.pk,
'device_role': device.device_role.pk,
@ -923,9 +958,10 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ConsolePortTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = ConsoleServerPort
@classmethod
def setUpTestData(cls):
@ -957,39 +993,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_consoleport(self):
"""
Test tracing a ConsolePort cable.
"""
consoleport = ConsolePort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
consoleserverport = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_consoleport')
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], consoleport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], consoleserverport.name)
class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = ConsolePort
@classmethod
def setUpTestData(cls):
@ -1021,39 +1029,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_consoleserverport(self):
"""
Test tracing a ConsoleServerPort cable.
"""
consoleserverport = ConsoleServerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
consoleport = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_consoleserverport')
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], consoleserverport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], consoleport.name)
class PowerPortTest(APIViewTestCases.APIViewTestCase):
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = PowerOutlet
@classmethod
def setUpTestData(cls):
@ -1085,39 +1065,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_powerport(self):
"""
Test tracing a PowerPort cable.
"""
powerport = PowerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
poweroutlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_powerport')
url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], powerport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], poweroutlet.name)
class PowerOutletTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = PowerPort
@classmethod
def setUpTestData(cls):
@ -1149,39 +1101,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_poweroutlet(self):
"""
Test tracing a PowerOutlet cable.
"""
poweroutlet = PowerOutlet.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
powerport = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_poweroutlet')
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], poweroutlet.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], powerport.name)
class InterfaceTest(APIViewTestCases.APIViewTestCase):
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@ -1245,45 +1169,17 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.unrestricted().first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
def test_trace_interface(self):
"""
Test tracing an Interface cable.
"""
interface_a = Interface.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface_b = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], interface_a.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface_b.name)
class FrontPortTest(APIViewTestCases.APIViewTestCase):
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = FrontPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@ -1334,39 +1230,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_frontport(self):
"""
Test tracing a FrontPort cable.
"""
frontport = FrontPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_frontport')
url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], frontport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class RearPortTest(APIViewTestCases.APIViewTestCase):
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = RearPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@ -1401,35 +1269,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_rearport(self):
"""
Test tracing a RearPort cable.
"""
rearport = RearPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
cable.save()
self.add_permissions('dcim.view_rearport')
url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], rearport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
@ -1640,11 +1479,11 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
self.assertEqual(Cable.objects.unrestricted().count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk)
self.assertEqual(cable.termination_a, consoleport1)
self.assertEqual(cable.termination_b, consoleserverport1)
@ -1705,12 +1544,12 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
consoleport1 = ConsolePort.objects.get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.get(pk=consoleserverport1.pk)
consoleport1 = ConsolePort.objects.unrestricted().get(pk=consoleport1.pk)
consoleserverport1 = ConsoleServerPort.objects.unrestricted().get(pk=consoleserverport1.pk)
self.assertEqual(consoleport1.connected_endpoint, consoleserverport1)
self.assertEqual(consoleserverport1.connected_endpoint, consoleport1)
@ -1735,11 +1574,11 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
self.assertEqual(Cable.objects.unrestricted().count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
powerport1 = PowerPort.objects.get(pk=powerport1.pk)
poweroutlet1 = PowerOutlet.objects.get(pk=poweroutlet1.pk)
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
powerport1 = PowerPort.objects.unrestricted().get(pk=powerport1.pk)
poweroutlet1 = PowerOutlet.objects.unrestricted().get(pk=poweroutlet1.pk)
self.assertEqual(cable.termination_a, powerport1)
self.assertEqual(cable.termination_b, poweroutlet1)
@ -1771,11 +1610,11 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
self.assertEqual(Cable.objects.unrestricted().count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
interface1 = Interface.objects.get(pk=interface1.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
interface1 = Interface.objects.unrestricted().get(pk=interface1.pk)
interface2 = Interface.objects.unrestricted().get(pk=interface2.pk)
self.assertEqual(cable.termination_a, interface1)
self.assertEqual(cable.termination_b, interface2)
@ -1836,12 +1675,12 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
interface1 = Interface.objects.get(pk=interface1.pk)
interface2 = Interface.objects.get(pk=interface2.pk)
interface1 = Interface.objects.unrestricted().get(pk=interface1.pk)
interface2 = Interface.objects.unrestricted().get(pk=interface2.pk)
self.assertEqual(interface1.connected_endpoint, interface2)
self.assertEqual(interface2.connected_endpoint, interface1)
@ -1875,11 +1714,11 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Cable.objects.count(), 1)
self.assertEqual(Cable.objects.unrestricted().count(), 1)
cable = Cable.objects.get(pk=response.data['id'])
interface1 = Interface.objects.get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
interface1 = Interface.objects.unrestricted().get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk)
self.assertEqual(cable.termination_a, interface1)
self.assertEqual(cable.termination_b, circuittermination1)
@ -1949,12 +1788,12 @@ class ConnectionTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
cable = Cable.objects.get(pk=response.data['id'])
cable = Cable.objects.unrestricted().get(pk=response.data['id'])
self.assertEqual(cable.termination_a.cable, cable)
self.assertEqual(cable.termination_b.cable, cable)
interface1 = Interface.objects.get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.get(pk=circuittermination1.pk)
interface1 = Interface.objects.unrestricted().get(pk=interface1.pk)
circuittermination1 = CircuitTermination.objects.unrestricted().get(pk=circuittermination1.pk)
self.assertEqual(interface1.connected_endpoint, circuittermination1)
self.assertEqual(circuittermination1.connected_endpoint, interface1)
@ -2003,7 +1842,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
brief_fields = ['id', 'master', 'member_count', 'url']
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):
@ -2040,34 +1879,35 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
# Create three VirtualChassis with three members each
virtual_chassis = (
VirtualChassis(master=devices[0], domain='domain-1'),
VirtualChassis(master=devices[3], domain='domain-2'),
VirtualChassis(master=devices[6], domain='domain-3'),
VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'),
VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'),
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
)
VirtualChassis.objects.bulk_create(virtual_chassis)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
Device.objects.unrestricted().filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
Device.objects.unrestricted().filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
Device.objects.unrestricted().filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
Device.objects.unrestricted().filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
Device.objects.unrestricted().filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
Device.objects.unrestricted().filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
cls.update_data = {
'master': devices[1].pk,
'name': 'Virtual Chassis X',
'domain': 'domain-x',
'master': devices[1].pk,
}
cls.create_data = [
{
'master': devices[9].pk,
'name': 'Virtual Chassis 4',
'domain': 'domain-4',
},
{
'master': devices[10].pk,
'name': 'Virtual Chassis 5',
'domain': 'domain-5',
},
{
'master': devices[11].pk,
'name': 'Virtual Chassis 6',
'domain': 'domain-6',
},
]

View File

@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
)
IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])

View File

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

View File

@ -813,14 +813,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
}
# TODO: Change base class to DeviceComponentTemplateViewTestCase
# Blocked by absence of bulk edit view for DeviceBays
class DeviceBayTemplateTestCase(
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
@classmethod
@ -848,6 +841,10 @@ class DeviceBayTemplateTestCase(
'name_pattern': 'Device Bay Template [4-6]',
}
cls.bulk_edit_data = {
'description': 'Foo bar',
}
class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = DeviceRole
@ -1194,10 +1191,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
):
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
@classmethod
@ -1563,16 +1557,7 @@ class CableTestCase(
}
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by standard creation, bulk creation views for VirtualChassis (member devices must be selected in bulk)
class VirtualChassisTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
@classmethod
@ -1587,7 +1572,6 @@ class VirtualChassisTestCase(
name='Device Role', slug='device-role-1'
)
# Create 9 member Devices
devices = (
Device(device_type=device_type, device_role=device_role, name='Device 1', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 2', site=site),
@ -1598,23 +1582,29 @@ class VirtualChassisTestCase(
Device(device_type=device_type, device_role=device_role, name='Device 7', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 8', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 10', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 11', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 12', site=site),
)
Device.objects.bulk_create(devices)
# Create three VirtualChassis with two members each
vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1')
# Create three VirtualChassis with three members each
vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1')
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3)
vc2 = VirtualChassis.objects.create(master=devices[3], domain='domain-2')
vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2')
Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3)
vc3 = VirtualChassis.objects.create(master=devices[6], domain='domain-3')
vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3')
Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1)
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2)
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3)
cls.form_data = {
'master': devices[1].pk,
'domain': 'domain-x',
'name': 'VC4',
'domain': 'domain-4',
# Management form data for VC members
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 3,
@ -1622,6 +1612,13 @@ class VirtualChassisTestCase(
'form-MAX_NUM_FORMS': 1000,
}
cls.csv_data = (
"name,domain,master",
"VC4,Domain 4,Device 10",
"VC5,Domain 5,Device 11",
"VC6,Domain 6,Device 12",
)
cls.bulk_edit_data = {
'domain': 'domain-x',
}

View File

@ -4,9 +4,9 @@ from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceEditView
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
VirtualChassis,
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
app_name = 'dcim'
@ -18,6 +18,7 @@ urlpatterns = [
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
@ -38,6 +39,7 @@ urlpatterns = [
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path('rack-groups/<int:pk>/delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
@ -46,6 +48,7 @@ urlpatterns = [
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
@ -78,6 +81,7 @@ urlpatterns = [
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<slug:slug>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
@ -142,7 +146,7 @@ urlpatterns = [
# Device bay templates
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
# path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
@ -153,6 +157,7 @@ urlpatterns = [
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<slug:slug>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
@ -161,6 +166,7 @@ urlpatterns = [
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<slug:slug>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
@ -187,12 +193,15 @@ urlpatterns = [
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
# TODO: Bulk rename, disconnect views for ConsolePorts
path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'),
path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('console-ports/<int:pk>/', views.ConsolePortView.as_view(), name='consoleport'),
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
@ -203,10 +212,12 @@ urlpatterns = [
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('console-server-ports/<int:pk>/', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@ -214,12 +225,15 @@ urlpatterns = [
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
# TODO: Bulk rename, disconnect views for PowerPorts
path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'),
path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'),
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('power-ports/<int:pk>/', views.PowerPortView.as_view(), name='powerport'),
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@ -230,10 +244,12 @@ urlpatterns = [
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('power-outlets/<int:pk>/', views.PowerOutletView.as_view(), name='poweroutlet'),
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@ -244,12 +260,12 @@ urlpatterns = [
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@ -260,10 +276,12 @@ urlpatterns = [
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path('front-ports/<int:pk>/', views.FrontPortView.as_view(), name='frontport'),
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@ -274,10 +292,12 @@ urlpatterns = [
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('rear-ports/<int:pk>/', views.RearPortView.as_view(), name='rearport'),
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Device bays
@ -287,8 +307,10 @@ urlpatterns = [
path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/', views.DeviceBayView.as_view(), name='devicebay'),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path('device-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@ -298,10 +320,13 @@ urlpatterns = [
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
# TODO: Bulk rename view for InventoryItems
path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'),
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path('inventory-items/<int:pk>/', views.InventoryItemView.as_view(), name='inventoryitem'),
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
@ -321,6 +346,7 @@ urlpatterns = [
# Virtual chassis
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'),

File diff suppressed because it is too large Load Diff

View File

@ -110,7 +110,7 @@ class ExportTemplateViewSet(ModelViewSet):
#
class TagViewSet(ModelViewSet):
queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
tagged_items=Count('extras_taggeditem_items', distinct=True)
)
serializer_class = serializers.TagSerializer

View File

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

View File

@ -540,7 +540,7 @@ class ConfigContextModel(models.Model):
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
for context in ConfigContext.objects.get_for_object(self):
for context in ConfigContext.objects.unrestricted().get_for_object(self):
data = deepmerge(data, context.data)
# If the object has local config context data defined, merge it last

View File

@ -22,14 +22,10 @@ class Tag(TagBase, ChangeLoggedModel):
blank=True,
)
objects = models.Manager()
restricted = RestrictedQuerySet.as_manager()
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
slug = slugify(tag, allow_unicode=True)

View File

@ -173,7 +173,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

View File

@ -1,21 +1,9 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, JobResult, Tag, TaggedItem
TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
{% if perms.taggit.delete_tag %}
<a href="{% url 'extras:tag_delete' slug=record.slug %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
{% endif %}
"""
TAGGED_ITEM = """
{% if value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
@ -64,16 +52,8 @@ OBJECTCHANGE_REQUEST_ID = """
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='extras:tag',
args=[Accessor('slug')]
)
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
color = ColorColumn()
actions = ButtonsColumn(Tag, pk_field='slug')
class Meta(BaseTable.Meta):
model = Tag

View File

@ -10,7 +10,7 @@ from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Tag
@classmethod

View File

@ -13,7 +13,6 @@ urlpatterns = [
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),

View File

@ -4,13 +4,15 @@ from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.db.models import Count, Prefetch, Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from django_tables2 import RequestConfig
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import copy_safe_request, shallow_compare_dict
@ -18,6 +20,7 @@ from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
ContentTypePermissionRequiredMixin,
)
from virtualization.models import Cluster, ClusterGroup
from . import filters, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, Report, JobResult, Script, Tag, TaggedItem
@ -30,7 +33,7 @@ from .scripts import get_scripts, run_script
#
class TagListView(ObjectListView):
queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
@ -40,71 +43,39 @@ class TagListView(ObjectListView):
table = tables.TagTable
class TagView(ObjectView):
queryset = Tag.restricted.all()
def get(self, request, slug):
tag = get_object_or_404(self.queryset, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).prefetch_related(
'content_type', 'content_object'
)
# Generate a table of all items tagged with this Tag
items_table = tables.TaggedItemTable(tagged_items)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(items_table)
return render(request, 'extras/tag.html', {
'tag': tag,
'items_count': tagged_items.count(),
'items_table': items_table,
})
class TagEditView(ObjectEditView):
queryset = Tag.restricted.all()
queryset = Tag.objects.all()
model_form = forms.TagForm
default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html'
class TagDeleteView(ObjectDeleteView):
queryset = Tag.restricted.all()
default_return_url = 'extras:tag_list'
queryset = Tag.objects.all()
class TagBulkImportView(BulkImportView):
queryset = Tag.restricted.all()
queryset = Tag.objects.all()
model_form = forms.TagCSVForm
table = tables.TagTable
default_return_url = 'extras:tag_list'
class TagBulkEditView(BulkEditView):
queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
table = tables.TagTable
form = forms.TagBulkEditForm
default_return_url = 'extras:tag_list'
class TagBulkDeleteView(BulkDeleteView):
queryset = Tag.restricted.annotate(
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(
'name'
)
table = tables.TagTable
default_return_url = 'extras:tag_list'
#
@ -123,6 +94,18 @@ class ConfigContextView(ObjectView):
queryset = ConfigContext.objects.all()
def get(self, request, pk):
# Extend queryset to prefetch related objects
self.queryset = self.queryset.prefetch_related(
Prefetch('regions', queryset=Region.objects.restrict(request.user)),
Prefetch('sites', queryset=Site.objects.restrict(request.user)),
Prefetch('roles', queryset=DeviceRole.objects.restrict(request.user)),
Prefetch('platforms', queryset=Platform.objects.restrict(request.user)),
Prefetch('clusters', queryset=Cluster.objects.restrict(request.user)),
Prefetch('cluster_groups', queryset=ClusterGroup.objects.restrict(request.user)),
Prefetch('tenants', queryset=Tenant.objects.restrict(request.user)),
Prefetch('tenant_groups', queryset=TenantGroup.objects.restrict(request.user)),
)
configcontext = get_object_or_404(self.queryset, pk=pk)
# Determine user's preferred output format
@ -144,7 +127,6 @@ class ConfigContextView(ObjectView):
class ConfigContextEditView(ObjectEditView):
queryset = ConfigContext.objects.all()
model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html'
@ -153,18 +135,15 @@ class ConfigContextBulkEditView(BulkEditView):
filterset = filters.ConfigContextFilterSet
table = tables.ConfigContextTable
form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(ObjectDeleteView):
queryset = ConfigContext.objects.all()
default_return_url = 'extras:configcontext_list'
class ConfigContextBulkDeleteView(BulkDeleteView):
queryset = ConfigContext.objects.all()
table = tables.ConfigContextTable
default_return_url = 'extras:configcontext_list'
class ObjectConfigContextView(ObjectView):
@ -264,9 +243,11 @@ class ObjectChangeLogView(View):
def get(self, request, model, **kwargs):
# Get object my model and kwargs (e.g. slug='foo')
queryset = model.objects.restrict(request.user, 'view')
obj = get_object_or_404(queryset, **kwargs)
# Handle QuerySet restriction of parent object if needed
if hasattr(model.objects, 'restrict'):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
else:
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
@ -299,6 +280,7 @@ class ObjectChangeLogView(View):
return render(request, 'extras/object_changelog.html', {
object_var: obj,
'instance': obj, # We'll eventually standardize on 'instance` for the object variable name
'table': objectchanges_table,
'base_template': base_template,
'active_tab': 'changelog',

View File

@ -1,5 +1,7 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
@ -9,10 +11,12 @@ from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
get_serializer_for_model,
)
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import *
@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False
)
assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
class Meta:
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id',
'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields',
'created', 'last_updated',
]
read_only_fields = ['family']
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data
class AvailableIPSerializer(serializers.Serializer):
"""

View File

@ -1,5 +1,5 @@
from django.conf import settings
from django.db.models import Count
from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
'nat_outside', 'tags',
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
)
serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet
@ -271,6 +270,9 @@ class VLANViewSet(CustomFieldModelViewSet):
#
class ServiceViewSet(ModelViewSet):
queryset = Service.objects.prefetch_related('device').prefetch_related('tags')
queryset = Service.objects.prefetch_related(
Prefetch('ipaddresses', queryset=IPAddress.objects.unrestricted()),
'device', 'virtual_machine', 'tags'
)
serializer_class = serializers.ServiceSerializer
filterset_class = filters.ServiceFilterSet

View File

@ -1,3 +1,5 @@
from django.db.models import Q
from .choices import IPAddressRoleChoices
# BGP ASN bounds
@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
# IPAddresses
#
IPADDRESS_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
Q(app_label='virtualization', model='vminterface')
)
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6

View File

@ -11,7 +11,7 @@ from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
field_name='pk',
label='Device (ID)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine',
queryset=VirtualMachine.objects.unrestricted(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.unrestricted(),
to_field_name='name',
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.unrestricted(),
to_field_name='name',
label='Interface (ID)',
label='Interface (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.unrestricted(),
label='Interface (ID)',
)
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.unrestricted(),
to_field_name='name',
label='VM interface (name)',
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.unrestricted(),
label='VM interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface',
label='Is assigned to an interface',
@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
return queryset.filter(address__net_mask_length=value)
def filter_device(self, queryset, name, value):
try:
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
devices = Device.objects.filter(**{'{}__in'.format(name): value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
vminterface__in=interface_ids
)
def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value)
return queryset.exclude(assigned_object_id__isnull=value)
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):

View File

@ -14,7 +14,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@ -522,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
#
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
interface = forms.ModelChoiceField(
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
filter_for={
'interface': 'device_id'
}
)
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vminterface': 'virtual_machine_id'
}
)
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Interface'
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@ -597,8 +620,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),
@ -610,32 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
if instance:
if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.device
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['virtual_machine'] = instance.assigned_object.virtual_machine
initial['vminterface'] = instance.assigned_object
if instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
).prefetch_related(
'device__primary_ip4',
'device__primary_ip6',
'virtual_machine__primary_ip4',
'virtual_machine__primary_ip6',
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent
if (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
@ -645,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if self.cleaned_data.get('primary_for_parent') and not interface:
self.add_error(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
# Set assigned object
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if interface:
self.instance.assigned_object = interface
ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress
interface.parent.primary_ip4 = ipaddress
else:
parent.primary_ip6 = ipaddress
parent.save()
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
interface.primary_ip6 = ipaddress
interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
return ipaddress
@ -742,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
help_text='Parent VM of assigned interface (if any)'
)
interface = CSVModelChoiceField(
queryset=Interface.objects.all(),
queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False,
to_field_name='name',
help_text='Assigned interface'
@ -761,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
if data:
# Limit interface queryset by assigned device or virtual machine
# Limit interface queryset by assigned device
if data.get('device'):
params = {
f"device__{self.fields['device'].to_field_name}": data.get('device')
}
self.fields['interface'].queryset = Interface.objects.filter(
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
elif data.get('virtual_machine'):
params = {
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
}
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean(self):
super().clean()
@ -790,6 +810,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs):
# Set interface assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM
@ -1194,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface_id__in=vc_interface_ids
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine
vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
)
else:
self.fields['ipaddresses'].choices = []

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def set_assigned_object_type(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
IPAddress = apps.get_model('ipam', 'IPAddress')
device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
IPAddress.objects.update(assigned_object_type=device_ct)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0036_standardize_description'),
]
operations = [
migrations.RenameField(
model_name='ipaddress',
old_name='interface',
new_name='assigned_object_id',
),
migrations.AlterField(
model_name='ipaddress',
name='assigned_object_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='ipaddress',
name='assigned_object_type',
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
preserve_default=False,
),
migrations.RunPython(
code=set_assigned_object_type
),
]

View File

@ -1,10 +1,11 @@
import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.db.models import F
from django.urls import reverse
from taggit.managers import TaggableManager
@ -14,7 +15,7 @@ from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .fields import IPNetworkField, IPAddressField
@ -215,7 +216,9 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
})
# Ensure that the aggregate being added is not covered by an existing aggregate
covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
covering_aggregates = Aggregate.objects.unrestricted().filter(
prefix__net_contains_or_equals=str(self.prefix)
)
if self.pk:
covering_aggregates = covering_aggregates.exclude(pk=self.pk)
if covering_aggregates:
@ -226,7 +229,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
})
# Ensure that the aggregate being added does not cover an existing aggregate
covered_aggregates = Aggregate.objects.filter(prefix__net_contained=str(self.prefix))
covered_aggregates = Aggregate.objects.unrestricted().filter(prefix__net_contained=str(self.prefix))
if self.pk:
covered_aggregates = covered_aggregates.exclude(pk=self.pk)
if covered_aggregates:
@ -254,7 +257,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
"""
Determine the prefix utilization of the aggregate and return it as a percentage.
"""
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
queryset = Prefix.objects.unrestricted().filter(prefix__net_contained_or_equal=str(self.prefix))
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
return int(float(child_prefixes.size) / self.prefix.size * 100)
@ -552,7 +555,10 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
"container", calculate utilization based on child prefixes. For all others, count child IP addresses.
"""
if self.status == PrefixStatusChoices.STATUS_CONTAINER:
queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf)
queryset = Prefix.objects.unrestricted().filter(
prefix__net_contained=str(self.prefix),
vrf=self.vrf
)
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
return int(float(child_prefixes.size) / self.prefix.size * 100)
else:
@ -606,13 +612,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
blank=True,
help_text='The functional role of this IP'
)
interface = models.ForeignKey(
to='dcim.Interface',
on_delete=models.CASCADE,
related_name='ip_addresses',
assigned_object_type = models.ForeignKey(
to=ContentType,
limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
assigned_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
assigned_object = GenericForeignKey(
ct_field='assigned_object_type',
fk_field='assigned_object_id'
)
nat_inside = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
@ -643,11 +658,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
objects = IPAddressManager()
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary',
'dns_name', 'description',
]
clone_fields = [
'vrf', 'tenant', 'status', 'role', 'description', 'interface',
'vrf', 'tenant', 'status', 'role', 'description',
]
STATUS_CLASS_MAP = {
@ -707,32 +722,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
)
})
if self.pk:
# Check for primary IP assignment that doesn't match the assigned device/VM
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
# Check for primary IP assignment that doesn't match the assigned device/VM
if self.pk and type(self.assigned_object) is Interface:
device = Device.objects.unrestricted().filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device:
if self.interface is None:
if self.assigned_object is None:
raise ValidationError({
'interface': "IP address is primary for device {} but not assigned".format(device)
'interface': f"IP address is primary for device {device} but not assigned to an interface"
})
elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device:
elif self.assigned_object.device != device:
raise ValidationError({
'interface': "IP address is primary for device {} but assigned to {} ({})".format(
device, self.interface.device, self.interface
)
'interface': f"IP address is primary for device {device} but assigned to "
f"{self.assigned_object.device} ({self.assigned_object})"
})
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
elif self.pk and type(self.assigned_object) is VMInterface:
vm = VirtualMachine.unrestricted().objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm:
if self.interface is None:
if self.assigned_object is None:
raise ValidationError({
'interface': "IP address is primary for virtual machine {} but not assigned".format(vm)
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
f"interface"
})
elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm:
elif self.interface.virtual_machine != vm:
raise ValidationError({
'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format(
vm, self.interface.virtual_machine, self.interface
)
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
})
def save(self, *args, **kwargs):
@ -743,29 +757,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the assigned Interface (if any)
try:
parent_obj = self.interface
except ObjectDoesNotExist:
parent_obj = None
# Annotate the assigned object, if any
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
related_object=self.assigned_object,
object_data=serialize_object(self)
)
def to_csv(self):
# Determine if this IP is primary for a Device
is_primary = False
if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
is_primary = True
elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
is_primary = True
else:
is_primary = False
obj_type = None
if self.assigned_object_type:
obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}'
return (
self.address,
@ -773,9 +785,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.tenant.name if self.tenant else None,
self.get_status_display(),
self.get_role_display(),
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.interface.name if self.interface else None,
obj_type,
self.assigned_object_id,
is_primary,
self.dns_name,
self.description,
@ -796,18 +807,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.address.prefixlen = value
mask_length = property(fset=_set_mask_length)
@property
def device(self):
if self.interface:
return self.interface.device
return None
@property
def virtual_machine(self):
if self.interface:
return self.interface.virtual_machine
return None
def get_status_class(self):
return self.STATUS_CLASS_MAP.get(self.status)

View File

@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """
@ -25,15 +25,6 @@ RIR_UTILIZATION = """
</div>
"""
RIR_ACTIONS = """
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
UTILIZATION_GRAPH = """
{% load helpers %}
{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}&mdash;{% endif %}
@ -47,15 +38,6 @@ ROLE_VLAN_COUNT = """
<a href="{% url 'ipam:vlan_list' %}?role={{ record.slug }}">{{ value }}</a>
"""
ROLE_ACTIONS = """
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
PREFIX_LINK = """
{% if record.has_children %}
<span class="text-nowrap" style="padding-left: {{ record.depth }}0px "><i class="fa fa-caret-right"></i></a>
@ -92,14 +74,6 @@ IPADDRESS_ASSIGN_LINK = """
{% endif %}
"""
IPADDRESS_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
"""
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@ -144,10 +118,7 @@ VLAN_ROLE_LINK = """
{% endif %}
"""
VLANGROUP_ACTIONS = """
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
VLANGROUP_ADD_VLAN = """
{% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?site={{ record.site_id }}&group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-xs btn-success">
@ -155,9 +126,6 @@ VLANGROUP_ACTIONS = """
</a>
{% endif %}
{% endwith %}
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
VLAN_MEMBER_UNTAGGED = """
@ -168,7 +136,7 @@ VLAN_MEMBER_UNTAGGED = """
VLAN_MEMBER_ACTIONS = """
{% if perms.dcim.change_interface %}
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
<a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:vminterface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
{% endif %}
"""
@ -222,11 +190,7 @@ class RIRTable(BaseTable):
aggregate_count = tables.Column(
verbose_name='Aggregates'
)
actions = tables.TemplateColumn(
template_code=RIR_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(RIR, pk_field='slug')
class Meta(BaseTable.Meta):
model = RIR
@ -330,11 +294,7 @@ class RoleTable(BaseTable):
orderable=False,
verbose_name='VLANs'
)
actions = tables.TemplateColumn(
template_code=ROLE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(Role, pk_field='slug')
class Meta(BaseTable.Meta):
model = Role
@ -431,18 +391,14 @@ class IPAddressTable(BaseTable):
tenant = tables.TemplateColumn(
template_code=TENANT_LINK
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
orderable=False
assigned = tables.BooleanColumn(
accessor='assigned_object_id'
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@ -465,11 +421,11 @@ class IPAddressDetailTable(IPAddressTable):
class Meta(IPAddressTable.Meta):
fields = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
'description', 'tags',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
)
@ -481,17 +437,13 @@ class IPAddressAssignTable(BaseTable):
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
parent = tables.TemplateColumn(
template_code=IPADDRESS_PARENT,
orderable=False
)
interface = tables.Column(
assigned_object = tables.Column(
orderable=False
)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
orderable = False
@ -532,10 +484,9 @@ class VLANGroupTable(BaseTable):
vlan_count = tables.Column(
verbose_name='VLANs'
)
actions = tables.TemplateColumn(
template_code=VLANGROUP_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
actions = ButtonsColumn(
model=VLANGroup,
prepend_template=VLANGROUP_ADD_VLAN
)
class Meta(BaseTable.Meta):

View File

@ -416,7 +416,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
"""
Attempt and fail to delete a VLAN with a Prefix assigned to it.
"""
vlan = VLAN.objects.first()
vlan = VLAN.objects.unrestricted().first()
Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'), vlan=vlan)
self.add_permissions('ipam.delete_vlan')

View File

@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import *
from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from virtualization.models import Cluster, ClusterType, VirtualMachine
from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup
@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
)
Device.objects.bulk_create(devices)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
)
VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
vminterfaces = (
VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
VMInterface.objects.bulk_create(vminterfaces)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase):
Tenant.objects.bulk_create(tenants)
ipaddresses = (
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
)
IPAddress.objects.bulk_create(ipaddresses)
@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase):
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'interface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_vminterface(self):
vminterfaces = VMInterface.objects.all()[:2]
params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'vminterface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'}

View File

@ -43,7 +43,7 @@ class TestPrefix(TestCase):
Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')),
Prefix(prefix=netaddr.IPNetwork('192.0.2.0/24')),
))
duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates()]
duplicate_prefix_pks = [p.pk for p in prefixes[0].get_duplicates().unrestricted()]
self.assertSetEqual(set(duplicate_prefix_pks), {prefixes[1].pk, prefixes[2].pk})
@ -227,7 +227,7 @@ class TestIPAddress(TestCase):
IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')),
IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')),
))
duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates()]
duplicate_ip_pks = [p.pk for p in ips[0].get_duplicates().unrestricted()]
self.assertSetEqual(set(duplicate_ip_pks), {ips[1].pk, ips[2].pk})

View File

@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tenant': None,
'status': IPAddressStatusChoices.STATUS_RESERVED,
'role': IPAddressRoleChoices.ROLE_ANYCAST,
'interface': None,
'nat_inside': None,
'dns_name': 'example',
'description': 'A new IP address',

View File

@ -24,6 +24,7 @@ urlpatterns = [
path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs/<slug:slug>/edit/', views.RIREditView.as_view(), name='rir_edit'),
path('rirs/<slug:slug>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
path('vrfs/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
# Aggregates
@ -43,6 +44,7 @@ urlpatterns = [
path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles/<slug:slug>/edit/', views.RoleEditView.as_view(), name='role_edit'),
path('roles/<slug:slug>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
path('roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
# Prefixes
@ -77,6 +79,7 @@ urlpatterns = [
path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),
path('vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),

93
netbox/ipam/utils.py Normal file
View File

@ -0,0 +1,93 @@
import netaddr
from .constants import *
from .models import Prefix, VLAN
def add_available_prefixes(parent, prefix_list):
"""
Create fake Prefix objects for all unallocated space within a prefix.
"""
# Find all unallocated space
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()]
# Concatenate and sort complete list of children
prefix_list = list(prefix_list) + available_prefixes
prefix_list.sort(key=lambda p: p.prefix)
return prefix_list
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
"""
Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be
considered usable (regardless of mask length).
"""
output = []
prev_ip = None
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
if not ipaddress_list:
return [(
int(last_ip_in_prefix - first_ip_in_prefix + 1),
'{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
)]
# Account for any available IPs before the first real IP
if ipaddress_list[0].address.ip > first_ip_in_prefix:
skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix)
first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
output.append((skipped_count, first_skipped))
# Iterate through existing IPs and annotate free ranges
for ip in ipaddress_list:
if prev_ip:
diff = int(ip.address.ip - prev_ip.address.ip)
if diff > 1:
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
output.append((diff - 1, first_skipped))
output.append(ip)
prev_ip = ip
# Include any remaining available IPs
if prev_ip.address.ip < last_ip_in_prefix:
skipped_count = int(last_ip_in_prefix - prev_ip.address.ip)
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
output.append((skipped_count, first_skipped))
return output
def add_available_vlans(vlan_group, vlans):
"""
Create fake records for all gaps between used VLANs
"""
if not vlans:
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
prev_vid = VLAN_VID_MAX
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
prev_vid = vlan.vid
if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
return vlans

View File

@ -1,6 +1,6 @@
import netaddr
from django.conf import settings
from django.db.models import Count, Q
from django.db.models import Count, Prefetch
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig
@ -11,100 +11,12 @@ from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
ObjectListView,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from . import filters, forms, tables
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
def add_available_prefixes(parent, prefix_list):
"""
Create fake Prefix objects for all unallocated space within a prefix.
"""
# Find all unallocated space
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()]
# Concatenate and sort complete list of children
prefix_list = list(prefix_list) + available_prefixes
prefix_list.sort(key=lambda p: p.prefix)
return prefix_list
def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
"""
Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be
considered usable (regardless of mask length).
"""
output = []
prev_ip = None
# Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31.
if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool:
first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1)
last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1)
else:
first_ip_in_prefix = netaddr.IPAddress(prefix.first)
last_ip_in_prefix = netaddr.IPAddress(prefix.last)
if not ipaddress_list:
return [(
int(last_ip_in_prefix - first_ip_in_prefix + 1),
'{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
)]
# Account for any available IPs before the first real IP
if ipaddress_list[0].address.ip > first_ip_in_prefix:
skipped_count = int(ipaddress_list[0].address.ip - first_ip_in_prefix)
first_skipped = '{}/{}'.format(first_ip_in_prefix, prefix.prefixlen)
output.append((skipped_count, first_skipped))
# Iterate through existing IPs and annotate free ranges
for ip in ipaddress_list:
if prev_ip:
diff = int(ip.address.ip - prev_ip.address.ip)
if diff > 1:
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
output.append((diff - 1, first_skipped))
output.append(ip)
prev_ip = ip
# Include any remaining available IPs
if prev_ip.address.ip < last_ip_in_prefix:
skipped_count = int(last_ip_in_prefix - prev_ip.address.ip)
first_skipped = '{}/{}'.format(prev_ip.address.ip + 1, prefix.prefixlen)
output.append((skipped_count, first_skipped))
return output
def add_available_vlans(vlan_group, vlans):
"""
Create fake records for all gaps between used VLANs
"""
if not vlans:
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
prev_vid = VLAN_VID_MAX
new_vlans = []
for vlan in vlans:
if vlan.vid - prev_vid > 1:
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
prev_vid = vlan.vid
if vlans[0].vid > VLAN_VID_MIN:
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
if prev_vid < VLAN_VID_MAX:
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
return vlans
from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
#
@ -136,19 +48,16 @@ class VRFEditView(ObjectEditView):
queryset = VRF.objects.all()
model_form = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list'
class VRFDeleteView(ObjectDeleteView):
queryset = VRF.objects.all()
default_return_url = 'ipam:vrf_list'
class VRFBulkImportView(BulkImportView):
queryset = VRF.objects.all()
model_form = forms.VRFCSVForm
table = tables.VRFTable
default_return_url = 'ipam:vrf_list'
class VRFBulkEditView(BulkEditView):
@ -156,14 +65,12 @@ class VRFBulkEditView(BulkEditView):
filterset = filters.VRFFilterSet
table = tables.VRFTable
form = forms.VRFBulkEditForm
default_return_url = 'ipam:vrf_list'
class VRFBulkDeleteView(BulkDeleteView):
queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet
table = tables.VRFTable
default_return_url = 'ipam:vrf_list'
#
@ -196,10 +103,12 @@ class RIRListView(ObjectListView):
'deprecated': 0,
'available': 0,
}
aggregate_list = Aggregate.objects.filter(prefix__family=family, rir=rir)
aggregate_list = Aggregate.objects.restrict(request.user).filter(prefix__family=family, rir=rir)
for aggregate in aggregate_list:
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))
queryset = Prefix.objects.restrict(request.user).filter(
prefix__net_contained_or_equal=str(aggregate.prefix)
)
# Find all consumed space for each prefix status (we ignore containers for this purpose).
active_prefixes = netaddr.cidr_merge(
@ -249,21 +158,22 @@ class RIRListView(ObjectListView):
class RIREditView(ObjectEditView):
queryset = RIR.objects.all()
model_form = forms.RIRForm
default_return_url = 'ipam:rir_list'
class RIRDeleteView(ObjectDeleteView):
queryset = RIR.objects.all()
class RIRBulkImportView(BulkImportView):
queryset = RIR.objects.all()
model_form = forms.RIRCSVForm
table = tables.RIRTable
default_return_url = 'ipam:rir_list'
class RIRBulkDeleteView(BulkDeleteView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet
table = tables.RIRTable
default_return_url = 'ipam:rir_list'
#
@ -345,19 +255,16 @@ class AggregateEditView(ObjectEditView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list'
class AggregateDeleteView(ObjectDeleteView):
queryset = Aggregate.objects.all()
default_return_url = 'ipam:aggregate_list'
class AggregateBulkImportView(BulkImportView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateCSVForm
table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list'
class AggregateBulkEditView(BulkEditView):
@ -365,14 +272,12 @@ class AggregateBulkEditView(BulkEditView):
filterset = filters.AggregateFilterSet
table = tables.AggregateTable
form = forms.AggregateBulkEditForm
default_return_url = 'ipam:aggregate_list'
class AggregateBulkDeleteView(BulkDeleteView):
queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet
table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list'
#
@ -387,20 +292,21 @@ class RoleListView(ObjectListView):
class RoleEditView(ObjectEditView):
queryset = Role.objects.all()
model_form = forms.RoleForm
default_return_url = 'ipam:role_list'
class RoleDeleteView(ObjectDeleteView):
queryset = Role.objects.all()
class RoleBulkImportView(BulkImportView):
queryset = Role.objects.all()
model_form = forms.RoleCSVForm
table = tables.RoleTable
default_return_url = 'ipam:role_list'
class RoleBulkDeleteView(BulkDeleteView):
queryset = Role.objects.all()
table = tables.RoleTable
default_return_url = 'ipam:role_list'
#
@ -517,7 +423,7 @@ class PrefixIPAddressesView(ObjectView):
# Find all IPAddresses belonging to this Prefix
ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
'vrf', 'primary_ip4_for', 'primary_ip6_for'
)
# Add available IP addresses to the table if requested
@ -556,20 +462,17 @@ class PrefixEditView(ObjectEditView):
queryset = Prefix.objects.all()
model_form = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
class PrefixDeleteView(ObjectDeleteView):
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
class PrefixBulkImportView(BulkImportView):
queryset = Prefix.objects.all()
model_form = forms.PrefixCSVForm
table = tables.PrefixTable
default_return_url = 'ipam:prefix_list'
class PrefixBulkEditView(BulkEditView):
@ -577,14 +480,12 @@ class PrefixBulkEditView(BulkEditView):
filterset = filters.PrefixFilterSet
table = tables.PrefixTable
form = forms.PrefixBulkEditForm
default_return_url = 'ipam:prefix_list'
class PrefixBulkDeleteView(BulkDeleteView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
table = tables.PrefixTable
default_return_url = 'ipam:prefix_list'
#
@ -593,7 +494,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
'vrf__tenant', 'tenant', 'nat_inside'
)
filterset = filters.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
@ -622,7 +523,7 @@ class IPAddressView(ObjectView):
).exclude(
pk=ipaddress.pk
).prefetch_related(
'nat_inside', 'interface__device'
'nat_inside'
)
# Exclude anycast IPs if this IP is anycast
if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
@ -630,9 +531,7 @@ class IPAddressView(ObjectView):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table
related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related(
'interface__device'
).exclude(
related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
address=str(ipaddress.address)
).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
@ -657,17 +556,21 @@ class IPAddressEditView(ObjectEditView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
default_return_url = 'ipam:ipaddress_list'
def alter_obj(self, obj, request, url_args, url_kwargs):
interface_id = request.GET.get('interface')
if interface_id:
if 'interface' in request.GET:
try:
obj.interface = Interface.objects.get(pk=interface_id)
obj.assigned_object = Interface.objects.get(pk=request.GET['interface'])
except (ValueError, Interface.DoesNotExist):
pass
elif 'vminterface' in request.GET:
try:
obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface'])
except (ValueError, VMInterface.DoesNotExist):
pass
return obj
@ -699,9 +602,7 @@ class IPAddressAssignView(ObjectView):
if form.is_valid():
addresses = self.queryset.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
)
addresses = self.queryset.prefetch_related('vrf', 'tenant')
# Limit to 100 results
addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
table = tables.IPAddressAssignTable(addresses)
@ -715,37 +616,33 @@ class IPAddressAssignView(ObjectView):
class IPAddressDeleteView(ObjectDeleteView):
queryset = IPAddress.objects.all()
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkCreateView(BulkCreateView):
queryset = IPAddress.objects.all()
form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
template_name = 'ipam/ipaddress_bulk_add.html'
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkImportView(BulkImportView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkEditView(BulkEditView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
form = forms.IPAddressBulkEditForm
default_return_url = 'ipam:ipaddress_list'
class IPAddressBulkDeleteView(BulkDeleteView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'
#
@ -762,21 +659,22 @@ class VLANGroupListView(ObjectListView):
class VLANGroupEditView(ObjectEditView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm
default_return_url = 'ipam:vlangroup_list'
class VLANGroupDeleteView(ObjectDeleteView):
queryset = VLANGroup.objects.all()
class VLANGroupBulkImportView(BulkImportView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list'
class VLANGroupBulkDeleteView(BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list'
class VLANGroupVLANsView(ObjectView):
@ -785,7 +683,9 @@ class VLANGroupVLANsView(ObjectView):
def get(self, request, pk):
vlan_group = get_object_or_404(self.queryset, pk=pk)
vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk)
vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk).prefetch_related(
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
)
vlans = add_available_vlans(vlan_group, vlans)
vlan_table = tables.VLANDetailTable(vlans)
@ -871,19 +771,16 @@ class VLANEditView(ObjectEditView):
queryset = VLAN.objects.all()
model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list'
class VLANDeleteView(ObjectDeleteView):
queryset = VLAN.objects.all()
default_return_url = 'ipam:vlan_list'
class VLANBulkImportView(BulkImportView):
queryset = VLAN.objects.all()
model_form = forms.VLANCSVForm
table = tables.VLANTable
default_return_url = 'ipam:vlan_list'
class VLANBulkEditView(BulkEditView):
@ -891,14 +788,12 @@ class VLANBulkEditView(BulkEditView):
filterset = filters.VLANFilterSet
table = tables.VLANTable
form = forms.VLANBulkEditForm
default_return_url = 'ipam:vlan_list'
class VLANBulkDeleteView(BulkDeleteView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet
table = tables.VLANTable
default_return_url = 'ipam:vlan_list'
#
@ -945,7 +840,6 @@ class ServiceBulkImportView(BulkImportView):
queryset = Service.objects.all()
model_form = forms.ServiceCSVForm
table = tables.ServiceTable
default_return_url = 'ipam:service_list'
class ServiceDeleteView(ObjectDeleteView):
@ -957,11 +851,9 @@ class ServiceBulkEditView(BulkEditView):
filterset = filters.ServiceFilterSet
table = tables.ServiceTable
form = forms.ServiceBulkEditForm
default_return_url = 'ipam:service_list'
class ServiceBulkDeleteView(BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet
table = tables.ServiceTable
default_return_url = 'ipam:service_list'

View File

@ -24,7 +24,7 @@ class ObjectPermissionBackend(ModelBackend):
Return all permissions granted to the user by an ObjectPermission.
"""
# Retrieve all assigned ObjectPermissions
object_permissions = ObjectPermission.objects.filter(
object_permissions = ObjectPermission.objects.unrestricted().filter(
Q(users=user_obj) |
Q(groups__user=user_obj)
).prefetch_related('object_types')

View File

@ -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 = 'netbox.authentication.RemoteUserBackend'

View File

@ -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', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])

View File

@ -168,356 +168,6 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
class ObjectPermissionViewTestCase(TestCase):
@classmethod
def setUpTestData(cls):
cls.sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(cls.sites)
cls.prefixes = (
Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]),
Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]),
Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]),
Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]),
Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]),
)
Prefix.objects.bulk_create(cls.prefixes)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object(self):
# Attempt to retrieve object without permission
response = self.client.get(self.prefixes[0].get_absolute_url())
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
response = self.client.get(self.prefixes[0].get_absolute_url())
self.assertHttpStatus(response, 200)
# Attempt to retrieve non-permitted object
response = self.client.get(self.prefixes[3].get_absolute_url())
self.assertHttpStatus(response, 404)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects(self):
# Attempt to list objects without permission
response = self.client.get(reverse('ipam:prefix_list'))
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(reverse('ipam:prefix_list'))
self.assertHttpStatus(response, 200)
self.assertIn(str(self.prefixes[0].prefix), str(response.content))
self.assertNotIn(str(self.prefixes[3].prefix), str(response.content))
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object(self):
initial_count = Prefix.objects.count()
form_data = {
'prefix': '10.0.9.0/24',
'site': self.sites[1].pk,
'status': PrefixStatusChoices.STATUS_ACTIVE,
}
# Attempt to create an object without permission
request = {
'path': reverse('ipam:prefix_add'),
'data': form_data,
'follow': False, # Do not follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view', 'add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create a non-permitted object
request = {
'path': reverse('ipam:prefix_add'),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertEqual(Prefix.objects.count(), initial_count)
# Create a permitted object
form_data['site'] = self.sites[0].pk
request = {
'path': reverse('ipam:prefix_add'),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertEqual(Prefix.objects.count(), initial_count + 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object(self):
form_data = {
'prefix': '10.0.9.0/24',
'site': self.sites[0].pk,
'status': PrefixStatusChoices.STATUS_RESERVED,
}
# Attempt to edit an object without permission
request = {
'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}),
'data': form_data,
'follow': False, # Do not follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view', 'change']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit a non-permitted object
request = {
'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 404)
# Edit a permitted object
request = {
'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(pk=self.prefixes[0].pk)
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object(self):
form_data = {
'confirm': True
}
# Attempt to delete object without permission
request = {
'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view', 'delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Delete permitted object
request = {
'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists())
# Attempt to delete non-permitted object
request = {
'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}),
'data': form_data,
'follow': True, # Follow 302 redirects
}
response = self.client.post(**request)
self.assertHttpStatus(response, 404)
self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_import_objects(self):
initial_count = Prefix.objects.count()
form_data = {
'csv': "prefix,status,site\n"
"10.0.9.0/24,Active,Site 1\n"
"10.0.10.0/24,Active,Site 2\n"
"10.0.11.0/24,Active,Site 3\n",
}
# Attempt to import objects without permission
request = {
'path': reverse('ipam:prefix_import'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create non-permitted objects
request = {
'path': reverse('ipam:prefix_import'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertEqual(Prefix.objects.count(), initial_count)
# Create a permitted object
form_data = {
'csv': "prefix,status,site\n"
"10.0.9.0/24,Active,Site 1\n"
"10.0.10.0/24,Active,Site 1\n"
"10.0.11.0/24,Active,Site 1\n",
}
request = {
'path': reverse('ipam:prefix_import'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertEqual(Prefix.objects.count(), initial_count + 3)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects(self):
form_data = {
'pk': [p.pk for p in self.prefixes],
'status': 'reserved',
'_apply': True,
}
# Attempt to edit objects without permission
request = {
'path': reverse('ipam:prefix_bulk_edit'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit non-permitted objects
request = {
'path': reverse('ipam:prefix_bulk_edit'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active')
# Edit permitted objects
form_data['pk'] = [p.pk for p in self.prefixes[:3]]
request = {
'path': reverse('ipam:prefix_bulk_edit'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved')
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects(self):
form_data = {
'pk': [p.pk for p in self.prefixes],
'confirm': True,
'_confirm': True,
}
# Attempt to delete objects without permission
request = {
'path': reverse('ipam:prefix_bulk_delete'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
constraints={'site__name': 'Site 1'},
actions=['view', 'delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete non-permitted object
request = {
'path': reverse('ipam:prefix_bulk_delete'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
# Delete permitted objects
form_data['pk'] = [p.pk for p in self.prefixes[:3]]
request = {
'path': reverse('ipam:prefix_bulk_delete'),
'data': form_data,
}
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists())
class ObjectPermissionAPIViewTestCase(TestCase):
client_class = APIClient

View File

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

View File

@ -1,17 +1,8 @@
import django_tables2 as tables
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
from .models import SecretRole, Secret
SECRETROLE_ACTIONS = """
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.secrets.change_secretrole %}
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
#
# Secret roles
@ -23,11 +14,7 @@ class SecretRoleTable(BaseTable):
secret_count = tables.Column(
verbose_name='Secrets'
)
actions = tables.TemplateColumn(
template_code=SECRETROLE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(SecretRole, pk_field='slug')
class Meta(BaseTable.Meta):
model = SecretRole

View File

@ -13,6 +13,7 @@ urlpatterns = [
path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
path('secret-roles/<slug:slug>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
path('secret-roles/<slug:slug>/delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'),
path('secret-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),
# Secrets

View File

@ -36,20 +36,21 @@ class SecretRoleListView(ObjectListView):
class SecretRoleEditView(ObjectEditView):
queryset = SecretRole.objects.all()
model_form = forms.SecretRoleForm
default_return_url = 'secrets:secretrole_list'
class SecretRoleDeleteView(ObjectDeleteView):
queryset = SecretRole.objects.all()
class SecretRoleBulkImportView(BulkImportView):
queryset = SecretRole.objects.all()
model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
class SecretRoleBulkDeleteView(BulkDeleteView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
#
@ -147,7 +148,6 @@ class SecretEditView(ObjectEditView):
class SecretDeleteView(ObjectDeleteView):
queryset = Secret.objects.all()
default_return_url = 'secrets:secret_list'
class SecretBulkImportView(BulkImportView):
@ -155,7 +155,6 @@ class SecretBulkImportView(BulkImportView):
model_form = forms.SecretCSVForm
table = tables.SecretTable
template_name = 'secrets/secret_import.html'
default_return_url = 'secrets:secret_list'
widget_attrs = {'class': 'requires-session-key'}
master_key = None
@ -203,11 +202,9 @@ class SecretBulkEditView(BulkEditView):
filterset = filters.SecretFilterSet
table = tables.SecretTable
form = forms.SecretBulkEditForm
default_return_url = 'secrets:secret_list'
class SecretBulkDeleteView(BulkDeleteView):
queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet
table = tables.SecretTable
default_return_url = 'secrets:secret_list'

View File

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

View File

@ -0,0 +1,103 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Port</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
{% if instance.connected_endpoint %}
<tr>
<td>Device</td>
<td>
<a href="{{ instance.connected_endpoint.device.get_absolute_url }}">{{ instance.connected_endpoint.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ instance.connected_endpoint.get_absolute_url }}">{{ instance.connected_endpoint.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.connected_endpoint.get_type_display|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.connected_endpoint.description|placeholder }}</td>
</tr>
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.connection_status %}
<span class="label label-success">{{ instance.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,103 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Port</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
{% if instance.connected_endpoint %}
<tr>
<td>Device</td>
<td>
<a href="{{ instance.connected_endpoint.device.get_absolute_url }}">{{ instance.connected_endpoint.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ instance.connected_endpoint.get_absolute_url }}">{{ instance.connected_endpoint.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.connected_endpoint.get_type_display|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.connected_endpoint.description|placeholder }}</td>
</tr>
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:consoleserverport_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.connection_status %}
<span class="label label-success">{{ instance.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -74,6 +74,9 @@
{% if perms.dcim.add_devicebay %}
<li><a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Device Bays</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li><a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}">Inventory Items</a></li>
{% endif %}
</ul>
</div>
{% endif %}
@ -326,34 +329,85 @@
{% plugin_left_page device %}
</div>
<div class="col-md-6">
{% if console_ports or power_ports %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console / Power</strong>
</div>
<table class="table table-hover panel-body component-list">
{% for cp in console_ports %}
{% include 'dcim/inc/consoleport.html' %}
{% endfor %}
{% for pp in power_ports %}
{% include 'dcim/inc/powerport.html' %}
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
<div class="panel-footer text-right noprint">
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a>
{% if console_ports %}
<form method="post">
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Ports</strong>
</div>
<table class="table table-hover panel-body component-list">
{% for cp in console_ports %}
{% include 'dcim/inc/consoleport.html' %}
{% endfor %}
</table>
<div class="panel-footer noprint">
{% if console_ports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if perms.dcim.add_powerport %}
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
{% if console_ports and perms.dcim.delete_consoleport %}
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if console_ports and perms.dcim.add_consoleport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div>
</form>
{% endif %}
{% if power_ports %}
<form method="post">
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Ports</strong>
</div>
<table class="table table-hover panel-body component-list">
{% for pp in power_ports %}
{% include 'dcim/inc/powerport.html' %}
{% endfor %}
</table>
<div class="panel-footer noprint">
{% if power_ports and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if power_ports and perms.dcim.delete_powerport %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if power_ports and perms.dcim.add_powerport %}
<div class="pull-right">
<a href="{% url 'dcim:powerport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power port
</a>
</div>
{% endif %}
</div>
</div>
</form>
{% endif %}
{% if power_ports and poweroutlets %}
<div class="panel panel-default">
@ -501,262 +555,242 @@
<div class="row">
<div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %}
<form method="post">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th colspan="2">Installed Device</th>
<th></th>
</tr>
</thead>
<tbody>
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' %}
{% empty %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bays</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
<td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th colspan="2">Installed Device</th>
<th></th>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if device_bays and perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
{% if perms.dcim.delete_devicebay %}
</form>
{% endif %}
</thead>
<tbody>
{% for devicebay in device_bays %}
{% include 'dcim/inc/devicebay.html' %}
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">&mdash; No device bays defined &mdash;</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if device_bays and perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
{% if perms.dcim.add_devicebay %}
<div class="pull-right">
<a href="{% url 'dcim:devicebay_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
</form>
{% endif %}
{% if interfaces %}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<form method="post">
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right noprint">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
</div>
<div class="col-md-2 pull-right noprint">
<input class="form-control interface-filter" type="text" placeholder="Filter" title="RegEx-enabled" style="height: 23px" />
</div>
</div>
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>Mode</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right noprint">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
{% if perms.dcim.delete_interface %}
</form>
{% endif %}
<div class="col-md-2 pull-right noprint">
<input class="form-control interface-filter" type="text" placeholder="Filter" title="Filter text (regular expressions supported)" style="height: 23px" />
</div>
</div>
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>Mode</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for iface in interfaces %}
{% include 'dcim/inc/interface.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_interface %}
<div class="pull-right">
<a href="{% url 'dcim:interface_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
</form>
{% endif %}
{% if consoleserverports %}
{% if perms.dcim.delete_consoleserverport %}
<form method="post">
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Console Server Ports</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for csp in consoleserverports %}
{% include 'dcim/inc/consoleserverport.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if consoleserverports and perms.dcim.delete_consoleserverport %}
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Cable</th>
<th colspan="2">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for csp in consoleserverports %}
{% include 'dcim/inc/consoleserverport.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if consoleserverports and perms.dcim.delete_consoleserverport %}
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<div class="pull-right">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
{% if perms.dcim.delete_consoleserverport %}
</form>
{% endif %}
</form>
{% endif %}
{% if poweroutlets %}
{% if perms.dcim.delete_poweroutlet %}
<form method="post">
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlets</strong>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Input/Leg</th>
<th>Description</th>
<th>Cable</th>
<th colspan="3">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for po in poweroutlets %}
{% include 'dcim/inc/poweroutlet.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if poweroutlets and perms.dcim.delete_poweroutlet %}
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
<table class="table table-hover table-headings panel-body component-list">
<thead>
<tr>
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Type</th>
<th>Input/Leg</th>
<th>Description</th>
<th>Cable</th>
<th colspan="3">Connection</th>
<th></th>
</tr>
</thead>
<tbody>
{% for po in poweroutlets %}
{% include 'dcim/inc/poweroutlet.html' %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if poweroutlets and perms.dcim.delete_poweroutlet %}
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<div class="pull-right">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
</a>
</div>
<div class="clearfix"></div>
{% endif %}
</div>
</div>
{% if perms.dcim.delete_poweroutlet %}
</form>
{% endif %}
</form>
{% endif %}
{% if front_ports %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
<div class="panel panel-default">
<div class="panel-heading">
<strong>Front Ports</strong>
@ -815,7 +849,6 @@
{% if rear_ports %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="device" value="{{ device.pk }}" />
<div class="panel panel-default">
<div class="panel-heading">
<strong>Rear Ports</strong>

View File

@ -0,0 +1,41 @@
{% extends 'base.html' %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% block header %}
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
<li><a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a></li>
<li><a href="{% url instance|viewname:"list" %}?device_id={{ instance.device.pk }}">{{ instance|meta:"verbose_name_plural"|bettertitle }}</a></li>
<li>{{ instance }}</li>
</ol>
</div>
</div>
<div class="pull-right noprint">
{% plugin_buttons instance %}
{% if request.user|can_change:instance %}
<a href="{% url instance|viewname:"edit" pk=instance.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>
{% endif %}
{% if request.user|can_delete:instance %}
<a href="{% url instance|viewname:"delete" pk=instance.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}
</div>
<h1>{% block title %}{{ instance.device }} / {{ instance }}{% endblock %}</h1>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ instance.get_absolute_url }}">{{ instance|meta:"verbose_name"|bettertitle }}</a>
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url instance|viewname:"changelog" pk=instance.pk %}">Change Log</a>
</li>
{% endif %}
</ul>
{% endblock %}

View File

@ -5,61 +5,61 @@
{% block content %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Chassis</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Model</td>
<td>{{ device.device_type.display_name }}</td>
</tr>
<tr>
<td>Serial Number</td>
<td><span>{{ device.serial|placeholder }}</span></td>
</tr>
<tr>
<td>Asset Tag</td>
<td><span>{{ device.asset_tag|placeholder }}</span></td>
</tr>
</table>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Hardware</strong>
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
<th>Name</th>
<th></th>
<th>Manufacturer</th>
<th>Part ID</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
<div class="col-md-12">
<form method="post">
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Inventory Items</strong>
</div>
{% endif %}
</div>
<table class="table table-hover table-condensed panel-body" id="hardware">
<thead>
<tr>
{% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %}
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
{% endif %}
<th>Name</th>
<th>Manufacturer</th>
<th>Part ID</th>
<th>Serial Number</th>
<th>Asset Tag</th>
<th>Discovered</th>
<th>Description</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in inventory_items %}
{% with template_name='dcim/inc/inventoryitem.html' indent=0 %}
{% include template_name %}
{% endwith %}
{% endfor %}
</tbody>
</table>
<div class="panel-footer noprint">
{% if inventory_items and perms.dcim.change_inventoryitem %}
<button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_edit" formaction="{% url 'dcim:inventoryitem_bulk_edit' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button>
{% endif %}
{% if inventory_items and perms.dcim.delete_inventoryitem %}
<button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<div class="pull-right">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ device.pk }}&return_url={% url 'dcim:device_inventory' pk=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>
</div>
{% endif %}
</div>
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -14,12 +14,8 @@
{% if perms.dcim.add_interface %}<li><a href="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
{% if perms.dcim.add_rearport %}<li><a href="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Rear Ports</a></li>{% endif %}
{% if perms.dcim.add_devicebay %}<li><a href="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Device Bays</a></li>{% endif %}
{% if perms.dcim.add_inventoryitem %}<li><a href="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Inventory Items</a></li>{% endif %}
</ul>
</div>
{% endif %}
{% if perms.dcim.add_virtualchassis %}
<button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
</button>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,70 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Device Bay</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Installed Device</strong>
</div>
{% if instance.installed_device %}
{% with device=instance.installed_device %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ device.get_absolute_url }}">{{ device }}</a>
</td>
</tr>
<tr>
<td>Device Type</td>
<td>{{ device.device_type }}</td>
</tr>
</table>
{% endwith %}
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -131,7 +131,7 @@
</tr>
<tr>
<td>Instances</td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ instance_count }}</a></td>
</tr>
</table>
</div>
@ -173,7 +173,7 @@
{% if devicetype.is_parent_device or devicebay_table.rows %}
<div class="row">
<div class="col-md-12">
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url=None delete_url='dcim:devicebaytemplate_bulk_delete' %}
{% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicebaytemplate_add' edit_url='dcim:devicebaytemplate_bulk_edit' delete_url='dcim:devicebaytemplate_bulk_delete' %}
</div>
</div>
{% endif %}

View File

@ -0,0 +1,91 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Front Port</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Rear Port</td>
<td>
<a href="{{ instance.rear_port.get_absolute_url }}">{{ instance.rear_port }}</a>
</td>
</tr>
<tr>
<td>Rear Port Position</td>
<td>{{ instance.rear_port_position }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:frontport_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.cable.status %}
<span class="label label-success">{{ instance.cable.get_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.cable.get_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -16,7 +16,9 @@
</tr>
<tr>
<td>Component</td>
<td>{{ termination }}</td>
<td>
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
</td>
</tr>
{% else %}
{# Circuit termination #}

View File

@ -1,8 +1,16 @@
<tr class="consoleport{% if cp.cable %} {{ cp.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_consoleport or perms.dcim.delete_consoleport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ cp.pk }}" />
</td>
{% endif %}
{# Name #}
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ cp }}
<i class="fa fa-fw fa-keyboard-o"></i>
<a href="{{ cp.get_absolute_url }}">{{ cp }}</a>
</td>
{# Type #}

View File

@ -11,7 +11,8 @@
{# Name #}
<td>
<i class="fa fa-fw fa-keyboard-o"></i> {{ csp }}
<i class="fa fa-fw fa-keyboard-o"></i>
<a href="{{ csp.get_absolute_url }}">{{ csp }}</a>
</td>
{# Type #}

View File

@ -9,7 +9,8 @@
{# Name #}
<td>
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i>
<a href="{{ devicebay.get_absolute_url }}">{{ devicebay.name }}</a>
</td>
{# Status #}

View File

@ -10,7 +10,8 @@
{# Name #}
<td>
<i class="fa fa-fw fa-square{% if not frontport.cable %}-o{% endif %}"></i> {{ frontport }}
<i class="fa fa-fw fa-square{% if not frontport.cable %}-o{% endif %}"></i>
<a href="{{ frontport.get_absolute_url }}">{{ frontport }}</a>
</td>
{# Type #}

View File

@ -166,7 +166,7 @@
</ul>
</span>
{% endif %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<a href="{% url 'dcim:interface_edit' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a>
{% endif %}
@ -176,7 +176,7 @@
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</button>
{% else %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<a href="{% url 'dcim:interface_delete' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
<i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
</a>
{% endif %}

View File

@ -1,11 +1,34 @@
{% load helpers %}
<tr>
<td style="padding-left: {{ indent|add:5 }}px">{{ item }}</td>
<td>{% if not item.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
<td>{{ item.manufacturer|default:"" }}</td>
<td>{{ item.part_id }}</td>
<td>{{ item.serial }}</td>
<td>{{ item.asset_tag|default:"" }}</td>
<td>{{ item.description }}</td>
{# Checkbox #}
{% if perms.dcim.change_inventoryitem or perms.dcim.delete_inventoryitem %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ item.pk }}" />
</td>
{% endif %}
<td style="padding-left: {{ indent|add:5 }}px">
<a href="{{ item.get_absolute_url }}">{{ item }}</a>
</td>
<td>
{% if item.manufacturer %}
<a href="{{ item.manufacturer.get_absolute_url }}">{{ item.manufacturer }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>{{ item.part_id|placeholder }}</td>
<td>{{ item.serial|placeholder }}</td>
<td>{{ item.asset_tag|placeholder }}</td>
<td>
{% if item.discovered %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>{{ item.description|placeholder }}</td>
<td class="text-right noprint">
{% if perms.dcim.change_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>

View File

@ -11,7 +11,8 @@
{# Name #}
<td>
<i class="fa fa-fw fa-bolt"></i> {{ po }}
<i class="fa fa-fw fa-bolt"></i>
<a href="{{ po.get_absolute_url }}">{{ po }}</a>
</td>
{# Type #}

View File

@ -1,8 +1,16 @@
<tr class="powerport{% if pp.cable %} {{ pp.cable.get_status_class }}{% endif %}">
{# Checkbox #}
{% if perms.dcim.change_powerport or perms.dcim.delete_powerport %}
<td class="pk">
<input name="pk" type="checkbox" value="{{ pp.pk }}" />
</td>
{% endif %}
{# Name #}
<td>
<i class="fa fa-fw fa-bolt"></i> {{ pp }}
<i class="fa fa-fw fa-bolt"></i>
<a href="{{ pp.get_absolute_url }}">{{ pp }}</a>
</td>
{# Type #}

View File

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

View File

@ -10,7 +10,8 @@
{# Name #}
<td>
<i class="fa fa-fw fa-square{% if not rearport.cable %}-o{% endif %}"></i> {{ rearport }}
<i class="fa fa-fw fa-square{% if not rearport.cable %}-o{% endif %}"></i>
<a href="{{ rearport.get_absolute_url }}">{{ rearport }}</a>
</td>
{# Type #}

View File

@ -1,258 +1,227 @@
{% extends 'base.html' %}
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% block header %}
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
{% if interface.device %}
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
{% else %}
<li><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></li>
{% endif %}
<li><a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a></li>
<li>{{ interface }}</li>
</ol>
</div>
</div>
<div class="pull-right noprint">
{% if perms.dcim.change_interface %}
<a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
<a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete
</a>
{% endif %}
</div>
<h1>{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}</h1>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ interface.get_absolute_url }}">Interface</a>
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Change Log</a>
</li>
{% endif %}
</ul>
{% endblock %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>{% if interface.device %}Device{% else %}Virtual Machine{% endif %}</td>
<td>
<a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ interface.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ interface.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ interface.get_type_display }}</td>
</tr>
<tr>
<td>Enabled</td>
<td>
{% if interface.enabled %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-danger"><i class="fa fa-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>LAG</td>
<td>
{% if interface.lag%}
<a href="{{ interface.lag.get_absolute_url }}">{{ interface.lag }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ interface.description|placeholder }} </td>
</tr>
<tr>
<td>MTU</td>
<td>{{ interface.mtu|placeholder }}</td>
</tr>
<tr>
<td>MAC Address</td>
<td>{{ interface.mac_address|placeholder }}</span></td>
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ interface.get_mode_display }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=interface.tags.all %}
</div>
<div class="col-md-6">
{% if interface.is_connectable %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
<strong>Interface</strong>
</div>
{% if interface.cable %}
<table class="table table-hover panel-body attr-table">
{% if connected_interface %}
<tr>
<td>Device</td>
<td>
<a href="{{ connected_interface.parent.get_absolute_url }}">{{ connected_interface.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ connected_interface.get_absolute_url }}">{{ connected_interface.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ connected_interface.get_type_display }}</td>
</tr>
<tr>
<td>Enabled</td>
<td>
{% if connected_interface.enabled %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-danger"><i class="fa fa-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>LAG</td>
<td>
{% if connected_interface.lag%}
<a href="{{ connected_interface.lag.get_absolute_url }}">{{ connected_interface.lag }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ connected_interface.description|placeholder }}</td>
</tr>
<tr>
<td>MTU</td>
<td>{{ connected_interface.mtu|placeholder }}</td>
</tr>
<tr>
<td>MAC Address</td>
<td>{{ connected_interface.mac_address|placeholder }}</td>
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ connected_interface.get_mode_display }}</td>
</tr>
{% elif connected_circuittermination %}
{% with ct=connected_circuittermination %}
<tr>
<td>Provider</td>
<td><a href="{{ ct.circuit.provider.get_absolute_url }}">{{ ct.circuit.provider }}</a></td>
</tr>
<tr>
<td>Circuit</td>
<td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>
</tr>
<tr>
<td>Side</td>
<td>{{ ct.term_side }}</td>
</tr>
{% endwith %}
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ interface.cable.get_absolute_url }}">{{ interface.cable }}</a>
<a href="{% url 'dcim:interface_trace' pk=interface.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if interface.connection_status %}
<span class="label label-success">{{ interface.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ interface.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% endif %}
{% if interface.is_lag %}
<div class="panel panel-default">
<div class="panel-heading"><strong>LAG Members</strong></div>
<table class="table table-hover table-headings panel-body">
<thead>
<tr>
<th>Parent</th>
<th>Interface</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for member in interface.member_interfaces.all %}
<tr>
<td>
<a href="{{ member.parent.get_absolute_url }}">{{ member.parent }}</a>
</td>
<td>
<a href="{{ member.get_absolute_url }}">{{ member }}</a>
</td>
<td>
{{ member.get_type_display }}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">No member interfaces</td>
</tr>
{% endfor %}
</tbody>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Enabled</td>
<td>
{% if instance.enabled %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-danger"><i class="fa fa-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>LAG</td>
<td>
{% if instance.lag%}
<a href="{{ instance.lag.get_absolute_url }}">{{ instance.lag }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }} </td>
</tr>
<tr>
<td>MTU</td>
<td>{{ instance.mtu|placeholder }}</td>
</tr>
<tr>
<td>MAC Address</td>
<td><span class="text-monospace">{{ instance.mac_address|placeholder }}</span></td>
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ instance.get_mode_display }}</td>
</tr>
</table>
</div>
{% endif %}
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
{% if instance.is_connectable %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
{% if connected_interface %}
<tr>
<td>Device</td>
<td>
<a href="{{ connected_interface.device.get_absolute_url }}">{{ connected_interface.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ connected_interface.get_absolute_url }}">{{ connected_interface.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ connected_interface.get_type_display }}</td>
</tr>
<tr>
<td>Enabled</td>
<td>
{% if connected_interface.enabled %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% else %}
<span class="text-danger"><i class="fa fa-close"></i></span>
{% endif %}
</td>
</tr>
<tr>
<td>LAG</td>
<td>
{% if connected_interface.lag%}
<a href="{{ connected_interface.lag.get_absolute_url }}">{{ connected_interface.lag }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ connected_interface.description|placeholder }}</td>
</tr>
<tr>
<td>MTU</td>
<td>{{ connected_interface.mtu|placeholder }}</td>
</tr>
<tr>
<td>MAC Address</td>
<td>{{ connected_interface.mac_address|placeholder }}</td>
</tr>
<tr>
<td>802.1Q Mode</td>
<td>{{ connected_interface.get_mode_display }}</td>
</tr>
{% elif connected_circuittermination %}
{% with ct=connected_circuittermination %}
<tr>
<td>Provider</td>
<td><a href="{{ ct.circuit.provider.get_absolute_url }}">{{ ct.circuit.provider }}</a></td>
</tr>
<tr>
<td>Circuit</td>
<td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>
</tr>
<tr>
<td>Side</td>
<td>{{ ct.term_side }}</td>
</tr>
{% endwith %}
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:interface_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.connection_status %}
<span class="label label-success">{{ instance.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% endif %}
{% if instance.is_lag %}
<div class="panel panel-default">
<div class="panel-heading"><strong>LAG Members</strong></div>
<table class="table table-hover table-headings panel-body">
<thead>
<tr>
<th>Parent</th>
<th>Interface</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for member in instance.member_interfaces.all %}
<tr>
<td>
<a href="{{ member.device.get_absolute_url }}">{{ member.device }}</a>
</td>
<td>
<a href="{{ member.get_absolute_url }}">{{ member }}</a>
</td>
<td>
{{ member.get_type_display }}
</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">No member interfaces</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% plugin_right_page instance %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %}
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %}
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,73 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Inventory Item</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Parent Item</td>
<td>
{% if instance.parent %}
<a href="{{ instance.parent.get_absolute_url }}">{{ instance.parent }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Manufacturer</td>
<td>
{% if instance.manufacturer %}
<a href="{{ instance.manufacturer.get_absolute_url }}">{{ instance.manufacturer }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Part ID</td>
<td>{{ instance.part_id|placeholder }}</td>
</tr>
<tr>
<td>Serial</td>
<td>{{ instance.serial|placeholder }}</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>{{ instance.asset_tag|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,111 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Outlet</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
<tr>
<td>Power Port</td>
<td>{{ instance.power_port }}</td>
</tr>
<tr>
<td>Feed Leg</td>
<td>{{ instance.get_feed_leg_display }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
{% if instance.connected_endpoint %}
<tr>
<td>Device</td>
<td>
<a href="{{ instance.connected_endpoint.device.get_absolute_url }}">{{ instance.connected_endpoint.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ instance.connected_endpoint.get_absolute_url }}">{{ instance.connected_endpoint.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.connected_endpoint.get_type_display|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.connected_endpoint.description|placeholder }}</td>
</tr>
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:poweroutlet_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.connection_status %}
<span class="label label-success">{{ instance.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,111 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Power Port</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
<tr>
<td>Maximum Draw</td>
<td>{{ instance.maximum_draw|placeholder }}</td>
</tr>
<tr>
<td>Allocated Draw</td>
<td>{{ instance.allocated_draw|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
{% if instance.connected_endpoint %}
<tr>
<td>Device</td>
<td>
<a href="{{ instance.connected_endpoint.device.get_absolute_url }}">{{ instance.connected_endpoint.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>
<a href="{{ instance.connected_endpoint.get_absolute_url }}">{{ instance.connected_endpoint.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.connected_endpoint.get_type_display|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.connected_endpoint.description|placeholder }}</td>
</tr>
{% endif %}
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:powerport_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.connection_status %}
<span class="label label-success">{{ instance.get_connection_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.get_connection_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

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

View File

@ -0,0 +1,85 @@
{% extends 'dcim/device_component.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Rear Port</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Device</td>
<td>
<a href="{{ instance.device.get_absolute_url }}">{{ instance.device }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ instance.name }}</td>
</tr>
<tr>
<td>Label</td>
<td>{{ instance.label|placeholder }}</td>
</tr>
<tr>
<td>Type</td>
<td>{{ instance.get_type_display }}</td>
</tr>
<tr>
<td>Positions</td>
<td>{{ instance.positions }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ instance.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=instance.tags.all %}
{% plugin_left_page instance %}
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Connection</strong>
</div>
{% if instance.cable %}
<table class="table table-hover panel-body attr-table">
<tr>
<td>Cable</td>
<td>
<a href="{{ instance.cable.get_absolute_url }}">{{ instance.cable }}</a>
<a href="{% url 'dcim:rearport_trace' pk=instance.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>
</td>
</tr>
<tr>
<td>Connection Status</td>
<td>
{% if instance.cable.status %}
<span class="label label-success">{{ instance.cable.get_status_display }}</span>
{% else %}
<span class="label label-info">{{ instance.cable.get_status_display }}</span>
{% endif %}
</td>
</tr>
</table>
{% else %}
<div class="panel-body text-muted">
Not connected
</div>
{% endif %}
</div>
{% plugin_right_page instance %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page instance %}
</div>
</div>
{% endblock %}

View File

@ -9,7 +9,9 @@
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a></li>
<li><a href="{% url 'dcim:virtualchassis_list' %}?site={{ virtualchassis.master.site.slug }}">{{ virtualchassis.master.site }}</a></li>
{% if virtualchassis.master %}
<li><a href="{% url 'dcim:virtualchassis_list' %}?site={{ virtualchassis.master.site.slug }}">{{ virtualchassis.master.site }}</a></li>
{% endif %}
<li>{{ virtualchassis }}</li>
</ol>
</div>
@ -63,7 +65,17 @@
<tr>
<td>Domain</td>
<td>{{ virtualchassis.domain|placeholder }}</td>
</tr>
<tr>
<td>Master</td>
<td>
{% if virtualchassis.master %}
<a href="{{ virtualchassis.master.get_absolute_url }}">{{ virtualchassis.master }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=virtualchassis.tags.all url='dcim:virtualchassis_list' %}
@ -81,7 +93,7 @@
<th>Master</th>
<th>Priority</th>
</tr>
{% for vc_member in virtualchassis.members.all %}
{% for vc_member in members %}
<tr{% if vc_member == device %} class="info"{% endif %}>
<td>
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>

View File

@ -0,0 +1,22 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Virtual Chassis</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.domain %}
{% render_field form.tags %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Member Devices</strong></div>
<div class="panel-body">
{% render_field form.site %}
{% render_field form.rack %}
{% render_field form.members %}
{% render_field form.initial_position %}
</div>
</div>
{% endblock %}

View File

@ -4,7 +4,7 @@
<strong>Tags</strong>
</div>
<div class="panel-body">
{% for tag in tags.unrestricted %}
{% for tag in tags.all %}
{% tag tag url %}
{% empty %}
<span class="text-muted">No tags assigned</span>

View File

@ -144,6 +144,12 @@
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
</li>
<li{% if not perms.dcim.view_virtualchassis %} class="disabled"{% endif %}>
{% if perms.dcim.add_virtualchassis %}
<div class="buttons pull-right">
<a href="{% url 'dcim:virtualchassis_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'dcim:virtualchassis_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
</li>
<li class="divider"></li>
@ -167,16 +173,6 @@
<a href="{% url 'dcim:manufacturer_list' %}">Manufacturers</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Inventory</li>
<li{% if not perms.dcim.view_inventoryitem %} class="disabled"{% endif %}>
{% if perms.dcim.add_inventoryitem %}
<div class="buttons pull-right">
<a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:inventoryitem_list' %}">Inventory Items</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Connections</li>
<li{% if not perms.dcim.view_cable %} class="disabled"{% endif %}>
{% if perms.dcim.add_cable %}
@ -261,6 +257,14 @@
{% endif %}
<a href="{% url 'dcim:devicebay_list' %}">Device Bays</a>
</li>
<li{% if not perms.dcim.view_inventoryitem %} class="disabled"{% endif %}>
{% if perms.dcim.add_inventoryitem %}
<div class="buttons pull-right">
<a href="{% url 'dcim:inventoryitem_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'dcim:inventoryitem_list' %}">Inventory Items</a>
</li>
</ul>
</li>
<li class="dropdown">
@ -372,6 +376,14 @@
{% endif %}
<a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a>
</li>
<li{% if not perms.virtualization.view_vminterface%} class="disabled"{% endif %}>
{% if perms.virtualization.add_vminterface %}
<div class="buttons pull-right">
<a href="{% url 'virtualization:vminterface_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'virtualization:vminterface_list' %}">Interfaces</a>
</li>
<li class="divider"></li>
<li class="dropdown-header">Clusters</li>
<li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>

View File

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

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