mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* Work on #7854 * Move to new URL scheme. * Fix PEP8 errors * Fix PEP8 errors * Add GraphQL and fix primary_ip missing * Fix PEP8 on GQL Type * Fix missing NestedSerializer. * Fix missing NestedSerializer & rename VDC to VDCs * Fix migration * Change Validation for identifier * Fix missing migration * Rebase to feature * Post-review changes * Remove VDC Type * Remove M2M Enforcement logic * Interface related changes * Add filter fields to filterset for Interface filter * Add form field to filterset form for Interface filter * Add VDC display to interface detail template * Remove VirtualDeviceContextTypeChoices * Accommodate recent changes in feature branch * Add tests Add missing search() * Update tests, and fix model form * Update test_api * Update test_api.InterfaceTest create_data * Fix issue with tests * Update interface serializer * Update serializer and tests * Update status to be required * Remove error message for constraint * Remove extraneous import * Re-ordered devices menu to place VDC below virtual chassis * Add helptext for `identifier` field * Fix breadcrumb link * Remove add interface link * Add missing tenant and status fields * Changes to tests as per Jeremy * Change for #9623 Co-authored-by: Jeremy Stretch <jstretch@ns1.com> * Update filterset form for status field * Remove Rename View * Change tabs to spaces * Update netbox/dcim/tables/devices.py Co-authored-by: Jeremy Stretch <jstretch@ns1.com> * Update netbox/dcim/tables/devices.py Co-authored-by: Jeremy Stretch <jstretch@ns1.com> * Fix tenant in bulk_edit * Apply suggestions from code review Co-authored-by: Jeremy Stretch <jstretch@ns1.com> * Add status field to table. * Re-order table fields. Co-authored-by: Jeremy Stretch <jstretch@ns1.com>
This commit is contained in:
parent
653acbf62c
commit
b374351154
@ -45,6 +45,7 @@ __all__ = [
|
||||
'NestedSiteSerializer',
|
||||
'NestedSiteGroupSerializer',
|
||||
'NestedVirtualChassisSerializer',
|
||||
'NestedVirtualDeviceContextSerializer',
|
||||
]
|
||||
|
||||
|
||||
@ -466,3 +467,12 @@ class NestedPowerFeedSerializer(WritableNestedSerializer):
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
fields = ['id', 'url', 'display', 'name', 'cable', '_occupied']
|
||||
|
||||
|
||||
class NestedVirtualDeviceContextSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualDeviceContext
|
||||
fields = ['id', 'url', 'display', 'name', 'identifier', 'device']
|
||||
|
@ -671,6 +671,22 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
|
||||
primary_ip = NestedIPAddressSerializer(read_only=True)
|
||||
primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
primary_ip6 = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'device', 'identifier', 'tenant', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'status', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class ModuleSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
@ -823,6 +839,12 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
vdcs = SerializedPKRelatedField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
serializer=NestedVirtualDeviceContextSerializer,
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
module = ComponentNestedModuleSerializer(
|
||||
required=False,
|
||||
allow_null=True
|
||||
@ -859,13 +881,13 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
|
||||
'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',
|
||||
'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type',
|
||||
'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type',
|
||||
'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
|
||||
'count_fhrp_groups', '_occupied',
|
||||
'id', 'url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge',
|
||||
'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role',
|
||||
'rf_channel', 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers',
|
||||
'link_peers_type', 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints',
|
||||
'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
@ -37,6 +37,7 @@ router.register('inventory-item-templates', views.InventoryItemTemplateViewSet)
|
||||
router.register('device-roles', views.DeviceRoleViewSet)
|
||||
router.register('platforms', views.PlatformViewSet)
|
||||
router.register('devices', views.DeviceViewSet)
|
||||
router.register('vdcs', views.VirtualDeviceContextViewSet)
|
||||
router.register('modules', views.ModuleViewSet)
|
||||
|
||||
# Device components
|
||||
|
@ -538,6 +538,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
return Response(response)
|
||||
|
||||
|
||||
class VirtualDeviceContextViewSet(NetBoxModelViewSet):
|
||||
queryset = VirtualDeviceContext.objects.prefetch_related(
|
||||
'device__device_type', 'device', 'tenant', 'tags',
|
||||
)
|
||||
serializer_class = serializers.VirtualDeviceContextSerializer
|
||||
filterset_class = filtersets.VirtualDeviceContextFilterSet
|
||||
|
||||
|
||||
class ModuleViewSet(NetBoxModelViewSet):
|
||||
queryset = Module.objects.prefetch_related(
|
||||
'device', 'module_bay', 'module_type__manufacturer', 'tags',
|
||||
|
@ -1399,3 +1399,20 @@ class PowerFeedPhaseChoices(ChoiceSet):
|
||||
(PHASE_SINGLE, 'Single phase'),
|
||||
(PHASE_3PHASE, 'Three-phase'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# VDC
|
||||
#
|
||||
class VirtualDeviceContextStatusChoices(ChoiceSet):
|
||||
key = 'VirtualDeviceContext.status'
|
||||
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_ACTIVE = 'active'
|
||||
STATUS_OFFLINE = 'offline'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_PLANNED, 'Planned', 'cyan'),
|
||||
(STATUS_ACTIVE, 'Active', 'green'),
|
||||
(STATUS_OFFLINE, 'Offline', 'red'),
|
||||
]
|
||||
|
@ -65,6 +65,7 @@ __all__ = (
|
||||
'SiteFilterSet',
|
||||
'SiteGroupFilterSet',
|
||||
'VirtualChassisFilterSet',
|
||||
'VirtualDeviceContextFilterSet',
|
||||
)
|
||||
|
||||
|
||||
@ -482,7 +483,7 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
'id', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -1009,6 +1010,44 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='VDC (ID)',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device model',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=VirtualDeviceContextStatusChoices
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = ['id', 'device', 'name', ]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(identifier=value.strip())
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
params = Q(primary_ip4__isnull=False) | Q(primary_ip6__isnull=False)
|
||||
if value:
|
||||
return queryset.filter(params)
|
||||
return queryset.exclude(params)
|
||||
|
||||
|
||||
class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_type__manufacturer',
|
||||
@ -1342,6 +1381,23 @@ class InterfaceFilterSet(
|
||||
to_field_name='rd',
|
||||
label='VRF (RD)',
|
||||
)
|
||||
vdc_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
label='Virtual Device Context',
|
||||
)
|
||||
vdc_identifier = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs__identifier',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
to_field_name='identifier',
|
||||
label='Virtual Device Context (Identifier)',
|
||||
)
|
||||
vdc = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vdcs__name',
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Virtual Device Context',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
|
@ -54,6 +54,7 @@ __all__ = (
|
||||
'SiteBulkEditForm',
|
||||
'SiteGroupBulkEditForm',
|
||||
'VirtualChassisBulkEditForm',
|
||||
'VirtualDeviceContextBulkEditForm'
|
||||
)
|
||||
|
||||
|
||||
@ -1398,3 +1399,24 @@ class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
(None, ('color', 'description')),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
|
||||
|
||||
class VirtualDeviceContextBulkEditForm(NetBoxModelBulkEditForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False
|
||||
)
|
||||
status = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(VirtualDeviceContextStatusChoices),
|
||||
widget=StaticSelect()
|
||||
)
|
||||
tenant = DynamicModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
)
|
||||
model = VirtualDeviceContext
|
||||
fieldsets = (
|
||||
(None, ('device', 'status', 'tenant')),
|
||||
)
|
||||
nullable_fields = ('device', 'tenant', )
|
||||
|
@ -43,6 +43,7 @@ __all__ = (
|
||||
'SiteCSVForm',
|
||||
'SiteGroupCSVForm',
|
||||
'VirtualChassisCSVForm',
|
||||
'VirtualDeviceContextCSVForm'
|
||||
)
|
||||
|
||||
|
||||
@ -1084,3 +1085,25 @@ class PowerFeedCSVForm(NetBoxModelCSVForm):
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
}
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
|
||||
class VirtualDeviceContextCSVForm(NetBoxModelCSVForm):
|
||||
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text='Assigned role'
|
||||
)
|
||||
tenant = CSVModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned tenant'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
'name', 'device', 'status', 'tenant', 'identifier', 'comments',
|
||||
]
|
||||
model = VirtualDeviceContext
|
||||
help_texts = {}
|
||||
|
@ -50,6 +50,7 @@ __all__ = (
|
||||
'SiteFilterForm',
|
||||
'SiteGroupFilterForm',
|
||||
'VirtualChassisFilterForm',
|
||||
'VirtualDeviceContextFilterForm'
|
||||
)
|
||||
|
||||
|
||||
@ -728,6 +729,37 @@ class DeviceFilterForm(
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class VirtualDeviceContextFilterForm(
|
||||
TenancyFilterForm,
|
||||
NetBoxModelFilterSetForm
|
||||
):
|
||||
model = VirtualDeviceContext
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter', 'tag')),
|
||||
('Hardware', ('device', 'status', )),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Miscellaneous', ('has_primary_ip',))
|
||||
)
|
||||
device = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(VirtualDeviceContextStatusChoices)
|
||||
)
|
||||
has_primary_ip = forms.NullBooleanField(
|
||||
required=False,
|
||||
label='Has a primary IP',
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Module
|
||||
fieldsets = (
|
||||
@ -1075,9 +1107,18 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
('Addressing', ('vrf_id', 'mac_address', 'wwn')),
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')),
|
||||
('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id',
|
||||
'device_id', 'vdc_id')),
|
||||
('Connection', ('cabled', 'connected', 'occupied')),
|
||||
)
|
||||
vdc_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device_id',
|
||||
},
|
||||
label=_('Virtual Device Context')
|
||||
)
|
||||
kind = MultipleChoiceField(
|
||||
choices=InterfaceKindChoices,
|
||||
required=False
|
||||
|
@ -62,6 +62,7 @@ __all__ = (
|
||||
'SiteGroupForm',
|
||||
'VCMemberSelectForm',
|
||||
'VirtualChassisForm',
|
||||
'VirtualDeviceContextForm'
|
||||
)
|
||||
|
||||
INTERFACE_MODE_HELP_TEXT = """
|
||||
@ -1378,6 +1379,14 @@ class PowerOutletForm(ModularDeviceComponentForm):
|
||||
|
||||
|
||||
class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
vdcs = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
@ -1452,7 +1461,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Interface', ('device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')),
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
@ -1466,7 +1475,7 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag',
|
||||
'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
|
||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||
@ -1636,3 +1645,74 @@ class InventoryItemRoleForm(NetBoxModelForm):
|
||||
fields = [
|
||||
'name', 'slug', 'color', 'description', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
|
||||
('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group',
|
||||
'tenant')),
|
||||
(None, ('tags', ))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'rack',
|
||||
'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
|
||||
'comments', 'tags'
|
||||
]
|
||||
help_texts = {}
|
||||
widgets = {
|
||||
'primary_ip4': StaticSelect(),
|
||||
'primary_ip6': StaticSelect(),
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import graphene
|
||||
|
||||
from netbox.graphql.fields import ObjectField, ObjectListField
|
||||
from .types import *
|
||||
from .types import VirtualDeviceContextType
|
||||
|
||||
|
||||
class DCIMQuery(graphene.ObjectType):
|
||||
@ -121,3 +122,6 @@ class DCIMQuery(graphene.ObjectType):
|
||||
|
||||
virtual_chassis = ObjectField(VirtualChassisType)
|
||||
virtual_chassis_list = ObjectListField(VirtualChassisType)
|
||||
|
||||
virtual_device_context = ObjectField(VirtualDeviceContextType)
|
||||
virtual_device_context_list = ObjectListField(VirtualDeviceContextType)
|
||||
|
@ -500,3 +500,11 @@ class VirtualChassisType(NetBoxObjectType):
|
||||
model = models.VirtualChassis
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.VirtualChassisFilterSet
|
||||
|
||||
|
||||
class VirtualDeviceContextType(NetBoxObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualDeviceContext
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.VirtualDeviceContextFilterSet
|
||||
|
54
netbox/dcim/migrations/0166_virtualdevicecontext.py
Normal file
54
netbox/dcim/migrations/0166_virtualdevicecontext.py
Normal file
@ -0,0 +1,54 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-10 16:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0063_standardize_description_comments'),
|
||||
('extras', '0083_savedfilter'),
|
||||
('tenancy', '0009_standardize_description_comments'),
|
||||
('dcim', '0165_standardize_description_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VirtualDeviceContext',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
|
||||
('description', models.CharField(blank=True, max_length=200)),
|
||||
('name', models.CharField(max_length=64)),
|
||||
('status', models.CharField(max_length=50)),
|
||||
('identifier', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='dcim.device')),
|
||||
('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
|
||||
('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress')),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vdcs', to='tenancy.tenant')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='vdcs',
|
||||
field=models.ManyToManyField(related_name='interfaces', to='dcim.virtualdevicecontext'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='virtualdevicecontext',
|
||||
constraint=models.UniqueConstraint(fields=('device', 'identifier'), name='dcim_virtualdevicecontext_device_identifiers'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='virtualdevicecontext',
|
||||
constraint=models.UniqueConstraint(fields=('device', 'name'), name='dcim_virtualdevicecontext_name'),
|
||||
),
|
||||
]
|
@ -531,6 +531,10 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
vdcs = models.ManyToManyField(
|
||||
to='dcim.VirtualDeviceContext',
|
||||
related_name='interfaces'
|
||||
)
|
||||
lag = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.SET_NULL,
|
||||
|
@ -34,6 +34,7 @@ __all__ = (
|
||||
'ModuleType',
|
||||
'Platform',
|
||||
'VirtualChassis',
|
||||
'VirtualDeviceContext',
|
||||
)
|
||||
|
||||
|
||||
@ -119,7 +120,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
)
|
||||
|
||||
clone_fields = (
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit',
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'weight', 'weight_unit'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -1062,3 +1063,81 @@ class VirtualChassis(PrimaryModel):
|
||||
)
|
||||
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class VirtualDeviceContext(PrimaryModel):
|
||||
device = models.ForeignKey(
|
||||
to='Device',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vdcs',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=VirtualDeviceContextStatusChoices,
|
||||
)
|
||||
identifier = models.PositiveSmallIntegerField(
|
||||
help_text='Unique identifier provided by the platform being virtualized (Example: Nexus VDC Identifier)',
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
primary_ip4 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
)
|
||||
primary_ip6 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv6'
|
||||
)
|
||||
tenant = models.ForeignKey(
|
||||
to='tenancy.Tenant',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vdcs',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('device', 'identifier',),
|
||||
name='%(app_label)s_%(class)s_device_identifiers'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('device', 'name',),
|
||||
name='%(app_label)s_%(class)s_name'
|
||||
),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:virtualdevicecontext', kwargs={'pk': self.pk})
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
if ConfigItem('PREFER_IPV4')() and self.primary_ip4:
|
||||
return self.primary_ip4
|
||||
elif self.primary_ip6:
|
||||
return self.primary_ip6
|
||||
elif self.primary_ip4:
|
||||
return self.primary_ip4
|
||||
else:
|
||||
return None
|
||||
|
@ -36,6 +36,7 @@ __all__ = (
|
||||
'PowerPortTable',
|
||||
'RearPortTable',
|
||||
'VirtualChassisTable',
|
||||
'VirtualDeviceContextTable'
|
||||
)
|
||||
|
||||
|
||||
@ -884,3 +885,43 @@ class VirtualChassisTable(NetBoxTable):
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
|
||||
|
||||
|
||||
class VirtualDeviceContextTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
device = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
primary_ip = tables.Column(
|
||||
linkify=True,
|
||||
order_by=('primary_ip4', 'primary_ip6'),
|
||||
verbose_name='IP Address'
|
||||
)
|
||||
primary_ip4 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv4 Address'
|
||||
)
|
||||
primary_ip6 = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='IPv6 Address'
|
||||
)
|
||||
|
||||
comments = columns.MarkdownColumn()
|
||||
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:vdc_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.VirtualDeviceContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'identifier', 'tenant', 'tenant_group',
|
||||
'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'identifier', 'status', 'tenant', 'primary_ip',
|
||||
)
|
||||
|
@ -1485,6 +1485,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
vdcs = (
|
||||
VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
|
||||
VirtualDeviceContext(name='VDC 2', identifier=2, device=device)
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
vlans = (
|
||||
VLAN(name='VLAN 1', vid=1),
|
||||
VLAN(name='VLAN 2', vid=2),
|
||||
@ -1533,6 +1539,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'vdcs': [vdcs[0].pk],
|
||||
'name': 'Interface 6',
|
||||
'type': 'virtual',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
@ -1543,6 +1550,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'vdcs': [vdcs[1].pk],
|
||||
'name': 'Interface 7',
|
||||
'type': InterfaceTypeChoices.TYPE_80211A,
|
||||
'tx_power': 10,
|
||||
@ -1551,6 +1559,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'vdcs': [vdcs[1].pk],
|
||||
'name': 'Interface 8',
|
||||
'type': InterfaceTypeChoices.TYPE_80211A,
|
||||
'tx_power': 10,
|
||||
@ -2163,3 +2172,57 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
|
||||
'type': REDUNDANT,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualDeviceContext
|
||||
brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
|
||||
bulk_update_data = {
|
||||
'status': 'planned',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Test Site', slug='test-site')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
|
||||
devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
vdcs = (
|
||||
VirtualDeviceContext(device=devices[1], name='VDC 1', identifier=1, status='active'),
|
||||
VirtualDeviceContext(device=devices[1], name='VDC 2', identifier=2, status='active'),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 1', identifier=1, status='active'),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 2', identifier=2, status='active'),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 3', identifier=3, status='active'),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 4', identifier=4, status='active'),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 5', identifier=5, status='active'),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'device': devices[0].pk,
|
||||
'status': 'active',
|
||||
'name': 'VDC 1',
|
||||
'identifier': 1,
|
||||
},
|
||||
{
|
||||
'device': devices[0].pk,
|
||||
'status': 'active',
|
||||
'name': 'VDC 2',
|
||||
'identifier': 2,
|
||||
},
|
||||
{
|
||||
'device': devices[1].pk,
|
||||
'status': 'active',
|
||||
'name': 'VDC 3',
|
||||
'identifier': 3,
|
||||
},
|
||||
]
|
||||
|
@ -2681,6 +2681,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
VRF.objects.bulk_create(vrfs)
|
||||
|
||||
# Virtual Device Context Creation
|
||||
vdcs = (
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[3], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||
@ -2793,6 +2800,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
interfaces[3].vdcs.set([vdcs[0], vdcs[1]])
|
||||
interfaces[4].vdcs.set([vdcs[0], vdcs[1]])
|
||||
interfaces[5].vdcs.set([vdcs[0]])
|
||||
interfaces[6].vdcs.set([vdcs[0]])
|
||||
interfaces[7].vdcs.set([vdcs[1]])
|
||||
|
||||
# Cables
|
||||
Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save()
|
||||
Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save()
|
||||
@ -2997,6 +3010,21 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_vdc(self):
|
||||
params = {'vdc': ['VDC 1']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
devices = Device.objects.last()
|
||||
vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
|
||||
params = {'vdc_id': vdc.values_list('pk', flat=True)}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_vdc_identifier(self):
|
||||
devices = Device.objects.last()
|
||||
vdc = VirtualDeviceContext.objects.filter(device=devices, name='VDC 2')
|
||||
params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
|
||||
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = FrontPort.objects.all()
|
||||
@ -4254,4 +4282,83 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
# TODO: Connection filters
|
||||
class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
filterset = VirtualDeviceContextFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(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(sites)
|
||||
|
||||
tenants = (
|
||||
Tenant(name='Tenant 1', slug='tenant-1'),
|
||||
Tenant(name='Tenant 2', slug='tenant-2'),
|
||||
Tenant(name='Tenant 3', slug='tenant-3'),
|
||||
)
|
||||
Tenant.objects.bulk_create(tenants)
|
||||
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
|
||||
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
|
||||
devices = (
|
||||
Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0]),
|
||||
Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1]),
|
||||
Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2]),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
vdcs = (
|
||||
VirtualDeviceContext(device=devices[0], name='VDC 1', identifier=1, status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[0], name='VDC 2', identifier=2, status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
VirtualDeviceContext(device=devices[1], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_OFFLINE),
|
||||
VirtualDeviceContext(device=devices[1], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_PLANNED),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 1', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
VirtualDeviceContext(device=devices[2], name='VDC 2', status=VirtualDeviceContextStatusChoices.STATUS_ACTIVE),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', type='virtual'),
|
||||
Interface(device=devices[0], name='Interface 2', type='virtual'),
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
interfaces[0].vdcs.set([vdcs[0]])
|
||||
interfaces[1].vdcs.set([vdcs[1]])
|
||||
|
||||
addresses = (
|
||||
IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
|
||||
IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(addresses)
|
||||
|
||||
vdcs[0].primary_ip4 = addresses[0]
|
||||
vdcs[0].save()
|
||||
vdcs[1].primary_ip4 = addresses[1]
|
||||
vdcs[1].save()
|
||||
|
||||
def test_device(self):
|
||||
params = {'device': ['Device 1', 'Device 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_status(self):
|
||||
params = {'status': ['active']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_device_id(self):
|
||||
devices = Device.objects.filter(name__in=['Device 1', 'Device 2'])
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_has_primary_ip(self):
|
||||
params = {'has_primary_ip': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'has_primary_ip': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
@ -588,3 +588,50 @@ class CableTestCase(TestCase):
|
||||
cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
devicetype = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
devicerole = DeviceRole.objects.create(
|
||||
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
|
||||
)
|
||||
self.device = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='TestDevice1', site=site
|
||||
)
|
||||
|
||||
def test_vdc_and_interface_creation(self):
|
||||
|
||||
vdc = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
vdc.full_clean()
|
||||
vdc.save()
|
||||
|
||||
interface = Interface(device=self.device, name='Eth1/1', type='10gbase-t')
|
||||
interface.full_clean()
|
||||
interface.save()
|
||||
|
||||
interface.vdcs.set([vdc])
|
||||
|
||||
def test_vdc_duplicate_name(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=2, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
||||
def test_vdc_duplicate_identifier(self):
|
||||
vdc1 = VirtualDeviceContext(device=self.device, name="VDC 1", identifier=1, status='active')
|
||||
vdc1.full_clean()
|
||||
vdc1.save()
|
||||
|
||||
vdc2 = VirtualDeviceContext(device=self.device, name="VDC 2", identifier=1, status='active')
|
||||
with self.assertRaises(ValidationError):
|
||||
vdc2.full_clean()
|
||||
|
@ -3076,3 +3076,48 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk}))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = VirtualDeviceContext
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
devices = [create_test_device(name='Device 1')]
|
||||
|
||||
vdcs = (
|
||||
VirtualDeviceContext(name='VDC 1', identifier=1, device=devices[0], status='active'),
|
||||
VirtualDeviceContext(name='VDC 2', identifier=2, device=devices[0], status='active'),
|
||||
VirtualDeviceContext(name='VDC 3', identifier=3, device=devices[0], status='active'),
|
||||
)
|
||||
VirtualDeviceContext.objects.bulk_create(vdcs)
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': devices[0].pk,
|
||||
'status': 'active',
|
||||
'name': 'VDC 4',
|
||||
'identifier': 4,
|
||||
'primary_ip4': None,
|
||||
'primary_ip6': None,
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,status,name,identifier",
|
||||
"Device 1,active,VDC 5,5",
|
||||
"Device 1,active,VDC 6,6",
|
||||
"Device 1,active,VDC 7,7",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,status",
|
||||
f"{vdcs[0].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
|
||||
f"{vdcs[1].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
|
||||
f"{vdcs[2].pk},{VirtualDeviceContextStatusChoices.STATUS_PLANNED}",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'status': VirtualDeviceContextStatusChoices.STATUS_OFFLINE,
|
||||
}
|
||||
|
@ -183,6 +183,14 @@ urlpatterns = [
|
||||
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||
path('devices/<int:pk>/', include(get_model_urls('dcim', 'device'))),
|
||||
|
||||
# Virtual Device Context
|
||||
path('vdcs/', views.VirtualDeviceContextListView.as_view(), name='virtualdevicecontext_list'),
|
||||
path('vdcs/add/', views.VirtualDeviceContextEditView.as_view(), name='virtualdevicecontext_add'),
|
||||
path('vdcs/import/', views.VirtualDeviceContextBulkImportView.as_view(), name='virtualdevicecontext_import'),
|
||||
path('vdcs/edit/', views.VirtualDeviceContextBulkEditView.as_view(), name='virtualdevicecontext_bulk_edit'),
|
||||
path('vdcs/delete/', views.VirtualDeviceContextBulkDeleteView.as_view(), name='virtualdevicecontext_bulk_delete'),
|
||||
path('vdcs/<int:pk>/', include(get_model_urls('dcim', 'virtualdevicecontext'))),
|
||||
|
||||
# Modules
|
||||
path('modules/', views.ModuleListView.as_view(), name='module_list'),
|
||||
path('modules/add/', views.ModuleEditView.as_view(), name='module_add'),
|
||||
|
@ -2442,6 +2442,14 @@ class InterfaceView(generic.ObjectView):
|
||||
queryset = Interface.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get assigned VDC's
|
||||
vdc_table = tables.VirtualDeviceContextTable(
|
||||
data=instance.vdcs.restrict(request.user, 'view').prefetch_related('device'),
|
||||
exclude=('tenant', 'tenant_group', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions', ),
|
||||
orderable=False
|
||||
)
|
||||
|
||||
# Get assigned IP addresses
|
||||
ipaddress_table = AssignedIPAddressesTable(
|
||||
data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
|
||||
@ -2479,6 +2487,7 @@ class InterfaceView(generic.ObjectView):
|
||||
)
|
||||
|
||||
return {
|
||||
'vdc_table': vdc_table,
|
||||
'ipaddress_table': ipaddress_table,
|
||||
'bridge_interfaces_table': bridge_interfaces_tables,
|
||||
'child_interfaces_table': child_interfaces_tables,
|
||||
@ -3562,3 +3571,55 @@ class PowerFeedBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
# Trace view
|
||||
register_model_view(PowerFeed, 'trace', kwargs={'model': PowerFeed})(PathTraceView)
|
||||
|
||||
|
||||
# VDC
|
||||
class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
filterset_form = forms.VirtualDeviceContextFilterForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext)
|
||||
class VirtualDeviceContextView(generic.ObjectView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
interfaces_table = tables.InterfaceTable(instance.interfaces, user=request.user)
|
||||
interfaces_table.configure(request)
|
||||
|
||||
return {
|
||||
'interfaces_table': interfaces_table,
|
||||
'interface_count': instance.interfaces.count(),
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext, 'edit')
|
||||
class VirtualDeviceContextEditView(generic.ObjectEditView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
form = forms.VirtualDeviceContextForm
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext, 'delete')
|
||||
class VirtualDeviceContextDeleteView(generic.ObjectDeleteView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
|
||||
|
||||
class VirtualDeviceContextBulkImportView(generic.BulkImportView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
model_form = forms.VirtualDeviceContextCSVForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
|
||||
|
||||
class VirtualDeviceContextBulkEditView(generic.BulkEditView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
table = tables.VirtualDeviceContextTable
|
||||
form = forms.VirtualDeviceContextBulkEditForm
|
||||
|
||||
|
||||
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
table = tables.VirtualDeviceContextTable
|
||||
|
@ -62,6 +62,7 @@ DEVICES_MENU = Menu(
|
||||
get_model_item('dcim', 'devicerole', 'Device Roles'),
|
||||
get_model_item('dcim', 'platform', 'Platforms'),
|
||||
get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'),
|
||||
get_model_item('dcim', 'virtualdevicecontext', 'Virtual Device Contexts'),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
@ -116,6 +116,7 @@
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panel_table.html' with table=vdc_table heading="Virtual Device Contexts" %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">Addressing</h5>
|
||||
<div class="card-body">
|
||||
|
68
netbox/templates/dcim/virtualdevicecontext.html
Normal file
68
netbox/templates/dcim/virtualdevicecontext.html
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<li class="breadcrumb-item"><a href="{% url 'dcim:virtualdevicecontext_list' %}">Virtual Device Contexts</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Virtual Device Context
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Device</th>
|
||||
<td>{{ object.device|linkify }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Identifier</th>
|
||||
<td>{{ object.identifier|placeholder }}</td>
|
||||
</tr>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Primary IPv4</th>
|
||||
<td>
|
||||
{{ object.primary_ip4|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Primary IPv6</th>
|
||||
<td>
|
||||
{{ object.primary_ip6|placeholder }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/panels/comments.html' %}
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% include 'inc/panels/custom_fields.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Interfaces</h5>
|
||||
<div class="card-body table-responsive">
|
||||
{% render_table interfaces_table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -61,6 +61,10 @@
|
||||
<h2><a href="{% url 'dcim:device_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
||||
<p>Devices</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:virtualdevicecontext_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vdc_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vdc_count }}</a></h2>
|
||||
<p>Virtual Device Contexts</p>
|
||||
</div>
|
||||
<div class="col col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
|
||||
<p>Cables</p>
|
||||
|
@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
|
||||
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, VirtualDeviceContext
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
|
||||
from netbox.views import generic
|
||||
from utilities.utils import count_related
|
||||
@ -109,6 +109,7 @@ class TenantView(generic.ObjectView):
|
||||
'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'vdc_count': VirtualDeviceContext.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||
|
Loading…
Reference in New Issue
Block a user