1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Initial work on #2018: Add name to VirtualChassis

This commit is contained in:
Jeremy Stretch
2020-06-24 15:12:22 -04:00
parent 2ac53afd96
commit 59c1e34024
14 changed files with 210 additions and 115 deletions

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

@ -694,12 +694,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

@ -4114,7 +4114,38 @@ class DeviceSelectionForm(forms.Form):
)
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class VirtualChassisCreateForm(BootstrapMixin, forms.ModelForm):
site = DynamicModelChoiceField(
queryset=Site.objects.all(),
required=False,
widget=APISelect(
filter_for={
'rack': 'site_id',
'members': 'site_id',
}
)
)
rack = DynamicModelChoiceField(
queryset=Rack.objects.all(),
required=False,
widget=APISelect(
filter_for={
'members': 'rack_id'
},
attrs={
'nullable': 'true',
}
)
)
members = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
)
initial_position = forms.IntegerField(
initial=1,
required=False,
help_text='Position of the first member device. Increases by one for each additional member.'
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@ -4123,12 +4154,47 @@ class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = VirtualChassis
fields = [
'master', 'domain', 'tags',
'name', 'domain', 'site', 'rack', 'members', 'initial_position', 'tags',
]
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
# Assign VC members
if instance.pk:
initial_position = self.cleaned_data.get('initial_position') or 1
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
member.virtual_chassis = instance
member.vc_position = i
member.save()
return instance
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
master = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = VirtualChassis
fields = [
'name', 'domain', 'master', 'tags',
]
widgets = {
'master': SelectWithPK(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['master'].queryset = Device.objects.filter(virtual_chassis=self.instance)
class BaseVCMemberFormSet(forms.BaseModelFormSet):
@ -4221,7 +4287,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form):
device = self.cleaned_data['device']
if device.virtual_chassis is not None:
raise forms.ValidationError(
"Device {} is already assigned to a virtual chassis.".format(device)
f"Device {device} is already assigned to a virtual chassis."
)
return device

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

@ -1572,9 +1572,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({
@ -1585,9 +1585,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({
@ -1757,7 +1757,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,
@ -1767,14 +1772,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})
@ -1783,9 +1788,9 @@ 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):
@ -1799,8 +1804,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
)
@ -1808,8 +1812,9 @@ class VirtualChassis(ChangeLoggedModel):
def to_csv(self):
return (
self.master,
self.name,
self.domain,
self.master.name if self.master else None,
)

View File

@ -10,14 +10,12 @@ from .models import Cable, Device, 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:
instance.master.virtual_chassis = instance
instance.master.vc_position = 1
instance.master.save()
@receiver(pre_delete, sender=VirtualChassis)

View File

@ -1167,7 +1167,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(
@ -1179,8 +1181,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

@ -2003,7 +2003,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,9 +2040,9 @@ 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)
@ -2053,21 +2053,22 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
Device.objects.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

@ -1563,16 +1563,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
@ -1602,19 +1593,22 @@ class VirtualChassisTestCase(
Device.objects.bulk_create(devices)
# Create three VirtualChassis with two members each
vc1 = VirtualChassis.objects.create(master=devices[0], domain='domain-1')
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,

View File

@ -2117,62 +2117,11 @@ class VirtualChassisView(ObjectView):
})
class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View):
class VirtualChassisCreateView(ObjectEditView):
queryset = VirtualChassis.objects.all()
def get_required_permission(self):
return 'dcim.add_virtualchassis'
def post(self, request):
# Get the list of devices being added to a VirtualChassis
pk_form = forms.DeviceSelectionForm(request.POST)
pk_form.full_clean()
if not pk_form.cleaned_data.get('pk'):
messages.warning(request, "No devices were selected.")
return redirect('dcim:device_list')
device_queryset = Device.objects.filter(
pk__in=pk_form.cleaned_data.get('pk')
).prefetch_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory(
model=Device,
formset=forms.BaseVCMemberFormSet,
form=forms.DeviceVCMembershipForm,
extra=0
)
if '_create' in request.POST:
vc_form = forms.VirtualChassisForm(request.POST)
vc_form.fields['master'].queryset = device_queryset
formset = VCMemberFormSet(request.POST, queryset=device_queryset)
if vc_form.is_valid() and formset.is_valid():
with transaction.atomic():
# Assign each device to the VirtualChassis before saving
virtual_chassis = vc_form.save()
devices = formset.save(commit=False)
for device in devices:
device.virtual_chassis = virtual_chassis
device.save()
return redirect(vc_form.cleaned_data['master'].get_absolute_url())
else:
vc_form = forms.VirtualChassisForm()
vc_form.fields['master'].queryset = device_queryset
formset = VCMemberFormSet(queryset=device_queryset)
return render(request, 'dcim/virtualchassis_edit.html', {
'pk_form': pk_form,
'vc_form': vc_form,
'formset': formset,
'return_url': reverse('dcim:device_list'),
})
model_form = forms.VirtualChassisCreateForm
template_name = 'dcim/virtualchassis_add.html'
default_return_url = 'dcim:virtualchassis_list'
class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
@ -2234,7 +2183,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
for member in members:
member.save()
return redirect(vc_form.cleaned_data['master'].get_absolute_url())
return redirect(virtual_chassis.get_absolute_url())
return render(request, 'dcim/virtualchassis_edit.html', {
'vc_form': vc_form,

View File

@ -17,9 +17,4 @@
</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

@ -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' %}

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

@ -144,6 +144,11 @@
<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>
</div>
{% endif %}
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
</li>
<li class="divider"></li>