mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-24 04:22:41 -06:00
Merge main into feature
This commit is contained in:
@@ -112,15 +112,32 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
|
||||
brief_fields = ('id', 'url', 'display', 'virtual_machine', 'name', 'description')
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate many-to-many VLAN assignments
|
||||
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [virtual_machine.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
|
||||
f"machine, or it must be global."
|
||||
})
|
||||
virtual_machine = None
|
||||
tagged_vlans = []
|
||||
|
||||
# #18887
|
||||
# There seem to be multiple code paths coming through here. Previously, we might either get
|
||||
# the VirtualMachine instance from self.instance or from incoming data. However, #18887
|
||||
# illustrated that this is also being called when a custom field pointing to an object_type
|
||||
# of VMInterface is on the right side of a custom-field assignment coming in from an API
|
||||
# request. As such, we need to check a third way to access the VirtualMachine
|
||||
# instance--where `data` is the VMInterface instance itself and we can get the associated
|
||||
# VirtualMachine via attribute access.
|
||||
if isinstance(data, dict):
|
||||
virtual_machine = self.instance.virtual_machine if self.instance else data.get('virtual_machine')
|
||||
tagged_vlans = data.get('tagged_vlans', [])
|
||||
elif isinstance(data, VMInterface):
|
||||
virtual_machine = data.virtual_machine
|
||||
tagged_vlans = data.tagged_vlans.all()
|
||||
|
||||
if virtual_machine:
|
||||
for vlan in tagged_vlans:
|
||||
if vlan.site not in [virtual_machine.site, None]:
|
||||
raise serializers.ValidationError({
|
||||
'tagged_vlans': f"VLAN {vlan} must belong to the same site as the interface's parent virtual "
|
||||
f"machine, or it must be global."
|
||||
})
|
||||
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
from netbox import denormalized
|
||||
|
||||
|
||||
class VirtualizationConfig(AppConfig):
|
||||
name = 'virtualization'
|
||||
@@ -15,10 +13,5 @@ class VirtualizationConfig(AppConfig):
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Register denormalized fields
|
||||
denormalized.register(VirtualMachine, 'cluster', {
|
||||
'site': '_site',
|
||||
})
|
||||
|
||||
# Register counters
|
||||
connect_counters(VirtualMachine)
|
||||
|
||||
@@ -38,6 +38,7 @@ class VirtualMachineStatusChoices(ChoiceSet):
|
||||
STATUS_STAGED = 'staged'
|
||||
STATUS_FAILED = 'failed'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
STATUS_PAUSED = 'paused'
|
||||
|
||||
CHOICES = [
|
||||
(STATUS_OFFLINE, _('Offline'), 'gray'),
|
||||
@@ -46,4 +47,5 @@ class VirtualMachineStatusChoices(ChoiceSet):
|
||||
(STATUS_STAGED, _('Staged'), 'blue'),
|
||||
(STATUS_FAILED, _('Failed'), 'red'),
|
||||
(STATUS_DECOMMISSIONING, _('Decommissioning'), 'yellow'),
|
||||
(STATUS_PAUSED, _('Paused'), 'orange'),
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
||||
from dcim.forms.mixins import ScopedBulkEditForm
|
||||
from dcim.models import Device, DeviceRole, Platform, Site
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import VLAN, VLANGroup, VRF
|
||||
from ipam.models import VLAN, VLANGroup, VLANTranslationPolicy, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import BulkRenameForm, add_blank_choice
|
||||
@@ -242,15 +242,23 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
vlan_translation_policy = DynamicModelChoiceField(
|
||||
queryset=VLANTranslationPolicy.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN Translation Policy')
|
||||
)
|
||||
|
||||
model = VMInterface
|
||||
fieldsets = (
|
||||
FieldSet('mtu', 'enabled', 'vrf', 'description'),
|
||||
FieldSet('parent', 'bridge', name=_('Related Interfaces')),
|
||||
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')),
|
||||
FieldSet(
|
||||
'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vlan_translation_policy',
|
||||
name=_('802.1Q Switching')
|
||||
),
|
||||
)
|
||||
nullable_fields = (
|
||||
'parent', 'bridge', 'mtu', 'vrf', 'description',
|
||||
'parent', 'bridge', 'mtu', 'vrf', 'description', 'vlan_translation_policy',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -49,7 +49,7 @@ class ComponentType(NetBoxObjectType):
|
||||
filters=ClusterFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
||||
class ClusterType(ContactsMixin, VLANGroupsMixin, NetBoxObjectType):
|
||||
type: Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
group: Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')] | None
|
||||
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
@@ -72,7 +72,7 @@ class ClusterType(VLANGroupsMixin, NetBoxObjectType):
|
||||
filters=ClusterGroupFilter,
|
||||
pagination=True
|
||||
)
|
||||
class ClusterGroupType(VLANGroupsMixin, OrganizationalObjectType):
|
||||
class ClusterGroupType(ContactsMixin, VLANGroupsMixin, OrganizationalObjectType):
|
||||
|
||||
clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]]
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from django.db import migrations
|
||||
from django.db.models import F, Sum
|
||||
from netbox.settings import DISK_BASE_UNIT
|
||||
|
||||
|
||||
def convert_disk_size(apps, schema_editor):
|
||||
VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
|
||||
VirtualMachine.objects.filter(disk__isnull=False).update(disk=F('disk') * 1000)
|
||||
VirtualMachine.objects.filter(disk__isnull=False).update(disk=F('disk') * DISK_BASE_UNIT)
|
||||
|
||||
VirtualDisk = apps.get_model('virtualization', 'VirtualDisk')
|
||||
VirtualDisk.objects.filter(size__isnull=False).update(size=F('size') * 1000)
|
||||
VirtualDisk.objects.filter(size__isnull=False).update(size=F('size') * DISK_BASE_UNIT)
|
||||
|
||||
# Recalculate disk size on all VMs with virtual disks
|
||||
id_list = VirtualDisk.objects.values_list('virtual_machine_id').distinct()
|
||||
|
||||
@@ -2,7 +2,7 @@ from django.db.models import Sum
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import VirtualDisk, VirtualMachine
|
||||
from .models import Cluster, VirtualDisk, VirtualMachine
|
||||
|
||||
|
||||
@receiver((post_delete, post_save), sender=VirtualDisk)
|
||||
@@ -14,3 +14,12 @@ def update_virtualmachine_disk(instance, **kwargs):
|
||||
VirtualMachine.objects.filter(pk=vm.pk).update(
|
||||
disk=vm.virtualdisks.aggregate(Sum('size'))['size__sum']
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Cluster)
|
||||
def update_virtualmachine_site(instance, **kwargs):
|
||||
"""
|
||||
Update the assigned site for all VMs to match that of the Cluster (if any).
|
||||
"""
|
||||
if instance._site:
|
||||
VirtualMachine.objects.filter(cluster=instance).update(site=instance._site)
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from dcim.tables.devices import BaseInterfaceTable
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
|
||||
from utilities.templatetags.helpers import humanize_megabytes
|
||||
from utilities.templatetags.helpers import humanize_disk_megabytes
|
||||
from virtualization.models import VirtualDisk, VirtualMachine, VMInterface
|
||||
from .template_code import *
|
||||
|
||||
@@ -93,7 +93,7 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
|
||||
)
|
||||
|
||||
def render_disk(self, value):
|
||||
return humanize_megabytes(value)
|
||||
return humanize_disk_megabytes(value)
|
||||
|
||||
|
||||
#
|
||||
@@ -122,7 +122,7 @@ class VMInterfaceTable(BaseInterfaceTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mtu', 'mode', 'description', 'tags', 'vrf',
|
||||
'primary_mac_address', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'qinq_svlan', 'created', 'last_updated',
|
||||
'qinq_svlan', 'created', 'last_updated', 'vlan_translation_policy',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')
|
||||
|
||||
@@ -183,7 +183,7 @@ class VirtualDiskTable(NetBoxTable):
|
||||
}
|
||||
|
||||
def render_size(self, value):
|
||||
return humanize_megabytes(value)
|
||||
return humanize_disk_megabytes(value)
|
||||
|
||||
|
||||
class VirtualMachineVirtualDiskTable(VirtualDiskTable):
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from django.test import tag
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
from rest_framework import status
|
||||
|
||||
from core.models import ObjectType
|
||||
from dcim.choices import InterfaceModeChoices
|
||||
from dcim.models import Site
|
||||
from extras.models import ConfigTemplate
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import ConfigTemplate, CustomField
|
||||
from ipam.choices import VLANQinQRoleChoices
|
||||
from ipam.models import VLAN, VRF
|
||||
from ipam.models import Prefix, VLAN, VRF
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine
|
||||
from virtualization.choices import *
|
||||
from virtualization.models import *
|
||||
@@ -350,6 +354,39 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@tag('regression')
|
||||
def test_set_vminterface_as_object_in_custom_field(self):
|
||||
cf = CustomField.objects.create(
|
||||
name='associated_interface',
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
related_object_type=ObjectType.objects.get_for_model(VMInterface),
|
||||
required=False
|
||||
)
|
||||
cf.object_types.set([ObjectType.objects.get_for_model(Prefix)])
|
||||
cf.save()
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('10.0.0.0/12'))
|
||||
vmi = VMInterface.objects.first()
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': prefix.pk})
|
||||
data = {
|
||||
'custom_fields': {
|
||||
'associated_interface': vmi.id,
|
||||
},
|
||||
}
|
||||
|
||||
self.add_permissions('ipam.change_prefix')
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
prefix_data = response.json()
|
||||
self.assertEqual(prefix_data['custom_fields']['associated_interface']['id'], vmi.id)
|
||||
|
||||
reloaded_prefix = Prefix.objects.get(pk=prefix.pk)
|
||||
self.assertEqual(prefix.pk, reloaded_prefix.pk)
|
||||
self.assertNotEqual(reloaded_prefix.cf['associated_interface'], None)
|
||||
|
||||
def test_bulk_delete_child_interfaces(self):
|
||||
interface1 = VMInterface.objects.get(name='Interface 1')
|
||||
virtual_machine = interface1.virtual_machine
|
||||
|
||||
@@ -4,13 +4,12 @@ from django.db.models import Prefetch, Sum
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from dcim.filtersets import DeviceFilterSet
|
||||
from dcim.forms import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
from dcim.tables import DeviceTable
|
||||
from extras.views import ObjectConfigContextView
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import IPAddress
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
@@ -415,51 +414,14 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'render-config')
|
||||
class VirtualMachineRenderConfigView(generic.ObjectView):
|
||||
class VirtualMachineRenderConfigView(ObjectRenderConfigView):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
template_name = 'virtualization/virtualmachine/render_config.html'
|
||||
base_template = 'virtualization/virtualmachine/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
weight=2100
|
||||
weight=2100,
|
||||
)
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
instance = self.get_object(**kwargs)
|
||||
context = self.get_extra_context(request, instance)
|
||||
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
response = context['config_template'].render_to_response(context=context['context_data'])
|
||||
return response
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'tab': self.tab,
|
||||
**context,
|
||||
})
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Compile context data
|
||||
context_data = instance.get_config_context()
|
||||
context_data.update({'virtualmachine': instance})
|
||||
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = None
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
|
||||
return {
|
||||
'config_template': config_template,
|
||||
'context_data': context_data,
|
||||
'rendered_config': rendered_config,
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(VirtualMachine, 'add', detail=False)
|
||||
@register_model_view(VirtualMachine, 'edit')
|
||||
|
||||
Reference in New Issue
Block a user