diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py
index 8749dc63f..a2286efed 100644
--- a/netbox/tenancy/api/serializers.py
+++ b/netbox/tenancy/api/serializers.py
@@ -97,7 +97,7 @@ class ContactAssignmentSerializer(NetBoxModelSerializer):
object = serializers.SerializerMethodField(read_only=True)
contact = NestedContactSerializer()
role = NestedContactRoleSerializer(required=False, allow_null=True)
- priority = ChoiceField(choices=ContactPriorityChoices, required=False)
+ priority = ChoiceField(choices=ContactPriorityChoices, allow_blank=True, required=False, default='')
class Meta:
model = ContactAssignment
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py
index 8ca4ae29c..dd14a412b 100644
--- a/netbox/tenancy/filtersets.py
+++ b/netbox/tenancy/filtersets.py
@@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet):
queryset=ContactRole.objects.all(),
label='Contact Role'
)
+ contact_group = TreeNodeMultipleChoiceFilter(
+ queryset=ContactGroup.objects.all(),
+ field_name='contacts__contact__group',
+ lookup_expr='in',
+ label='Contact group',
+ )
#
diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py
index 15d7773b7..02589d733 100644
--- a/netbox/tenancy/forms/filtersets.py
+++ b/netbox/tenancy/forms/filtersets.py
@@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Tenant
fieldsets = (
(None, ('q', 'tag', 'group_id')),
- ('Contacts', ('contact', 'contact_role'))
+ ('Contacts', ('contact', 'contact_role', 'contact_group'))
)
group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py
index 5dcad1d43..5e78bc540 100644
--- a/netbox/tenancy/forms/forms.py
+++ b/netbox/tenancy/forms/forms.py
@@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form):
required=False,
label=_('Contact Role')
)
+ contact_group = DynamicModelMultipleChoiceField(
+ queryset=ContactGroup.objects.all(),
+ required=False,
+ label=_('Contact Group')
+ )
diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py
index 17abc5a5b..234dc2ad7 100644
--- a/netbox/tenancy/tables/contacts.py
+++ b/netbox/tenancy/tables/contacts.py
@@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable):
)
contact_count = columns.LinkedCountColumn(
viewname='tenancy:contact_list',
- url_params={'role_id': 'pk'},
+ url_params={'group_id': 'pk'},
verbose_name='Contacts'
)
tags = columns.TagColumn(
diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py
index 5577d90e0..8f18423be 100644
--- a/netbox/tenancy/tables/tenants.py
+++ b/netbox/tenancy/tables/tenants.py
@@ -38,7 +38,7 @@ class TenantTable(NetBoxTable):
linkify=True
)
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 195871813..f6f95b123 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404
from circuits.models import Circuit
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
-from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN
+from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
from netbox.views import generic
from utilities.utils import count_related
from virtualization.models import VirtualMachine, Cluster
@@ -35,7 +35,7 @@ class TenantGroupView(generic.ObjectView):
tenants = Tenant.objects.restrict(request.user, 'view').filter(
group=instance
)
- tenants_table = tables.TenantTable(tenants, exclude=('group',))
+ tenants_table = tables.TenantTable(tenants, user=request.user, exclude=('group',))
tenants_table.configure(request)
return {
@@ -104,8 +104,9 @@ class TenantView(generic.ObjectView):
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
- 'prefix_count': Prefix.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(),
+ 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
@@ -184,7 +185,7 @@ class ContactGroupView(generic.ObjectView):
contacts = Contact.objects.restrict(request.user, 'view').filter(
group=instance
)
- contacts_table = tables.ContactTable(contacts, exclude=('group',))
+ contacts_table = tables.ContactTable(contacts, user=request.user, exclude=('group',))
contacts_table.configure(request)
return {
@@ -250,7 +251,7 @@ class ContactRoleView(generic.ObjectView):
contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
role=instance
)
- contacts_table = tables.ContactAssignmentTable(contact_assignments)
+ contacts_table = tables.ContactAssignmentTable(contact_assignments, user=request.user)
contacts_table.columns.hide('role')
contacts_table.configure(request)
@@ -307,7 +308,7 @@ class ContactView(generic.ObjectView):
contact_assignments = ContactAssignment.objects.restrict(request.user, 'view').filter(
contact=instance
)
- assignments_table = tables.ContactAssignmentTable(contact_assignments)
+ assignments_table = tables.ContactAssignmentTable(contact_assignments, user=request.user)
assignments_table.columns.hide('contact')
assignments_table.configure(request)
diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py
index df9af0f19..51e0c5b26 100644
--- a/netbox/users/api/nested_serializers.py
+++ b/netbox/users/api/nested_serializers.py
@@ -28,6 +28,11 @@ class NestedUserSerializer(WritableNestedSerializer):
model = User
fields = ['id', 'url', 'display', 'username']
+ def get_display(self, obj):
+ if full_name := obj.get_full_name():
+ return f"{obj.username} ({full_name})"
+ return obj.username
+
class NestedTokenSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail')
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 4b1f5bff3..b48a14d5c 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -45,6 +45,11 @@ class UserSerializer(ValidatedModelSerializer):
return user
+ def get_display(self, obj):
+ if full_name := obj.get_full_name():
+ return f"{obj.username} ({full_name})"
+ return obj.username
+
class GroupSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='users-api:group-detail')
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 40ff78b98..5372353c0 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -176,11 +176,11 @@ class UserConfig(models.Model):
@receiver(post_save, sender=User)
-def create_userconfig(instance, created, **kwargs):
+def create_userconfig(instance, created, raw=False, **kwargs):
"""
- Automatically create a new UserConfig when a new User is created.
+ Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
"""
- if created:
+ if created and not raw:
config = get_config()
UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save()
diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py
index f83fc6a7c..68e71610c 100644
--- a/netbox/utilities/forms/fields/dynamic.py
+++ b/netbox/utilities/forms/fields/dynamic.py
@@ -88,7 +88,12 @@ class DynamicModelChoiceMixin:
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
+
if data:
+ # When the field is multiple choice pass the data as a list if it's not already
+ if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list:
+ data = [data]
+
field_name = getattr(self, 'to_field_name') or 'pk'
filter = self.filter(field_name=field_name)
try:
@@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
widget = widgets.APISelectMultiple
def clean(self, value):
- """
- When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
- string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
- """
+ value = value or []
+
+ # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
+ # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value]
+
return super().clean(value)
diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py
index 4a3db0a3c..44ad5ac47 100644
--- a/netbox/utilities/templatetags/builtins/filters.py
+++ b/netbox/utilities/templatetags/builtins/filters.py
@@ -150,15 +150,15 @@ def render_markdown(value):
value = strip_tags(value)
# Sanitize Markdown links
- pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)'
+ pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)'
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
# Sanitize Markdown reference links
- pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)'
+ pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)'
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
# Render Markdown
- html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()])
+ html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
# If the string is not empty wrap it in rendered-markdown to style tables
if html:
diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py
index 466b5e22b..52ccd002d 100644
--- a/netbox/utilities/testing/utils.py
+++ b/netbox/utilities/testing/utils.py
@@ -34,15 +34,16 @@ def post_data(data):
return ret
-def create_test_device(name):
+def create_test_device(name, site=None, **attrs):
"""
Convenience method for creating a Device (e.g. for component testing).
"""
- site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
+ if site is None:
+ site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1')
manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer)
devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1')
- device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole)
+ device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs)
return device
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index afdf50b96..bd01b5533 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -1,7 +1,9 @@
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
-from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
+from dcim.api.nested_serializers import (
+ NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer,
+)
from dcim.choices import InterfaceModeChoices
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer
from ipam.models import VLAN
@@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
type = NestedClusterTypeSerializer()
group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None)
+ status = ChoiceField(choices=ClusterStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
site = NestedSiteSerializer(required=False, allow_null=True, default=None)
device_count = serializers.IntegerField(read_only=True)
@@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer):
class Meta:
model = Cluster
fields = [
- 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields',
- 'created', 'last_updated', 'device_count', 'virtualmachine_count',
+ 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
]
@@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer):
class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
- site = NestedSiteSerializer(read_only=True)
- cluster = NestedClusterSerializer()
+ site = NestedSiteSerializer(required=False, allow_null=True)
+ cluster = NestedClusterSerializer(required=False, allow_null=True)
+ device = NestedDeviceSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
platform = NestedPlatformSerializer(required=False, allow_null=True)
@@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
class Meta:
model = VirtualMachine
fields = [
- 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
- 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class Meta(VirtualMachineSerializer.Meta):
fields = [
- 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip',
- 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags',
- 'custom_fields', 'config_context', 'created', 'last_updated',
+ 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
+ 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data',
+ 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index 665114881..d2a90ae34 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet):
class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
queryset = VirtualMachine.objects.prefetch_related(
- 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags'
)
filterset_class = filtersets.VirtualMachineFilterSet
diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py
index 693e53df6..2cf6357e1 100644
--- a/netbox/virtualization/choices.py
+++ b/netbox/virtualization/choices.py
@@ -1,6 +1,28 @@
from utilities.choices import ChoiceSet
+#
+# Clusters
+#
+
+class ClusterStatusChoices(ChoiceSet):
+ key = 'Cluster.status'
+
+ STATUS_PLANNED = 'planned'
+ STATUS_STAGING = 'staging'
+ STATUS_ACTIVE = 'active'
+ STATUS_DECOMMISSIONING = 'decommissioning'
+ STATUS_OFFLINE = 'offline'
+
+ CHOICES = [
+ (STATUS_PLANNED, 'Planned', 'cyan'),
+ (STATUS_STAGING, 'Staging', 'blue'),
+ (STATUS_ACTIVE, 'Active', 'green'),
+ (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'),
+ (STATUS_OFFLINE, 'Offline', 'red'),
+ ]
+
+
#
# VirtualMachines
#
diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py
index 5a2aa8b42..00d3e2313 100644
--- a/netbox/virtualization/filtersets.py
+++ b/netbox/virtualization/filtersets.py
@@ -1,7 +1,7 @@
import django_filters
from django.db.models import Q
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.filtersets import LocalConfigContextFilterSet
from ipam.models import VRF
from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
@@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
to_field_name='slug',
label='Cluster type (slug)',
)
+ status = django_filters.MultipleChoiceFilter(
+ choices=ClusterStatusChoices,
+ null_value=None
+ )
class Meta:
model = Cluster
@@ -146,39 +150,48 @@ class VirtualMachineFilterSet(
to_field_name='name',
label='Cluster',
)
+ device_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=Device.objects.all(),
+ label='Device (ID)',
+ )
+ device = django_filters.ModelMultipleChoiceFilter(
+ field_name='device__name',
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ label='Device',
+ )
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
- field_name='cluster__site__region',
+ field_name='site__region',
lookup_expr='in',
to_field_name='slug',
label='Region (slug)',
)
site_group_id = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
label='Site group (ID)',
)
site_group = TreeNodeMultipleChoiceFilter(
queryset=SiteGroup.objects.all(),
- field_name='cluster__site__group',
+ field_name='site__group',
lookup_expr='in',
to_field_name='slug',
label='Site group (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
- field_name='cluster__site__slug',
+ field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py
index d5d33df2a..88dee3978 100644
--- a/netbox/virtualization/forms/bulk_edit.py
+++ b/netbox/virtualization/forms/bulk_edit.py
@@ -2,7 +2,7 @@ from django import forms
from dcim.choices import InterfaceModeChoices
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import VLAN, VRF
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
@@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
queryset=ClusterGroup.objects.all(),
required=False
)
+ status = forms.ChoiceField(
+ choices=add_blank_choice(ClusterStatusChoices),
+ required=False,
+ initial='',
+ widget=StaticSelect()
+ )
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
@@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
model = Cluster
fieldsets = (
- (None, ('type', 'group', 'tenant',)),
+ (None, ('type', 'group', 'status', 'tenant',)),
('Site', ('region', 'site_group', 'site',)),
)
nullable_fields = (
@@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
initial='',
widget=StaticSelect(),
)
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all(),
+ required=False
+ )
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'site_id': '$site'
+ }
+ )
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ query_params={
+ 'cluster_id': '$cluster'
+ }
)
role = DynamicModelChoiceField(
queryset=DeviceRole.objects.filter(
@@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
model = VirtualMachine
fieldsets = (
- (None, ('cluster', 'status', 'role', 'tenant', 'platform')),
+ (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')),
('Resources', ('vcpus', 'memory', 'disk'))
)
nullable_fields = (
- 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
)
@@ -223,8 +243,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
# See 5643
if 'pk' in self.initial:
site = None
- interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related(
- 'virtual_machine__cluster__site'
+ interfaces = VMInterface.objects.filter(
+ pk__in=self.initial['pk']
+ ).prefetch_related(
+ 'virtual_machine__site'
)
# Check interface sites. First interface should set site, further interfaces will either continue the
diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py
index eab6fc9e7..2d7ee52e2 100644
--- a/netbox/virtualization/forms/bulk_import.py
+++ b/netbox/virtualization/forms/bulk_import.py
@@ -1,5 +1,5 @@
from dcim.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Platform, Site
+from dcim.models import Device, DeviceRole, Platform, Site
from ipam.models import VRF
from netbox.forms import NetBoxModelCSVForm
from tenancy.models import Tenant
@@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm):
required=False,
help_text='Assigned cluster group'
)
+ status = CSVChoiceField(
+ choices=ClusterStatusChoices,
+ help_text='Operational status'
+ )
site = CSVModelChoiceField(
queryset=Site.objects.all(),
to_field_name='name',
@@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm):
class Meta:
model = Cluster
- fields = ('name', 'type', 'group', 'site', 'comments')
+ fields = ('name', 'type', 'group', 'status', 'site', 'comments')
class VirtualMachineCSVForm(NetBoxModelCSVForm):
@@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
choices=VirtualMachineStatusChoices,
help_text='Operational status'
)
+ site = CSVModelChoiceField(
+ queryset=Site.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Assigned site'
+ )
cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(),
to_field_name='name',
+ required=False,
help_text='Assigned cluster'
)
+ device = CSVModelChoiceField(
+ queryset=Device.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text='Assigned device within cluster'
+ )
role = CSVModelChoiceField(
queryset=DeviceRole.objects.filter(
vm_role=True
@@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm):
class Meta:
model = VirtualMachine
fields = (
- 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
+ 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
+ 'comments',
)
diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py
index 2f386e889..e15a76a43 100644
--- a/netbox/virtualization/forms/filtersets.py
+++ b/netbox/virtualization/forms/filtersets.py
@@ -1,7 +1,7 @@
from django import forms
from django.utils.translation import gettext as _
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from extras.forms import LocalConfigContextFilterForm
from ipam.models import VRF
from netbox.forms import NetBoxModelFilterSetForm
@@ -29,16 +29,20 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm):
class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
model = ClusterGroup
tag = TagFilterField(model)
+ fieldsets = (
+ (None, ('q', 'tag')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
+ )
class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
model = Cluster
fieldsets = (
(None, ('q', 'tag')),
- ('Attributes', ('group_id', 'type_id')),
+ ('Attributes', ('group_id', 'type_id', 'status')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
type_id = DynamicModelMultipleChoiceField(
queryset=ClusterType.objects.all(),
@@ -50,6 +54,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
required=False,
label=_('Region')
)
+ status = MultipleChoiceField(
+ choices=ClusterStatusChoices,
+ required=False
+ )
site_group_id = DynamicModelMultipleChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
@@ -83,11 +91,11 @@ class VirtualMachineFilterForm(
model = VirtualMachine
fieldsets = (
(None, ('q', 'tag')),
- ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')),
+ ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')),
('Location', ('region_id', 'site_group_id', 'site_id')),
('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')),
('Tenant', ('tenant_group_id', 'tenant_id')),
- ('Contacts', ('contact', 'contact_role')),
+ ('Contacts', ('contact', 'contact_role', 'contact_group')),
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
@@ -106,6 +114,11 @@ class VirtualMachineFilterForm(
required=False,
label=_('Cluster')
)
+ device_id = DynamicModelMultipleChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ label=_('Device')
+ )
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py
index 314b0bddf..cfafd7e39 100644
--- a/netbox/virtualization/forms/models.py
+++ b/netbox/virtualization/forms/models.py
@@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm):
comments = CommentField()
fieldsets = (
- ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')),
+ ('Cluster', ('name', 'type', 'group', 'status', 'tags')),
+ ('Site', ('region', 'site_group', 'site')),
('Tenancy', ('tenant_group', 'tenant')),
)
class Meta:
model = Cluster
fields = (
- 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
+ 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags',
)
+ widgets = {
+ 'status': StaticSelect(),
+ }
class ClusterAddDevicesForm(BootstrapMixin, forms.Form):
@@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm):
class VirtualMachineForm(TenancyForm, NetBoxModelForm):
+ site = DynamicModelChoiceField(
+ queryset=Site.objects.all()
+ )
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
@@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
query_params={
- 'group_id': '$cluster_group'
+ 'site_id': '$site',
+ 'group_id': '$cluster_group',
+ }
+ )
+ device = DynamicModelChoiceField(
+ queryset=Device.objects.all(),
+ required=False,
+ query_params={
+ 'cluster_id': '$cluster'
}
)
role = DynamicModelChoiceField(
@@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
fieldsets = (
('Virtual Machine', ('name', 'role', 'status', 'tags')),
- ('Cluster', ('cluster_group', 'cluster')),
+ ('Cluster', ('site', 'cluster_group', 'cluster', 'device')),
('Tenancy', ('tenant_group', 'tenant')),
('Management', ('platform', 'primary_ip4', 'primary_ip6')),
('Resources', ('vcpus', 'memory', 'disk')),
@@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
class Meta:
model = VirtualMachine
fields = [
- 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4',
- 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data',
+ 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant',
+ 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags',
+ 'local_context_data',
]
help_texts = {
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered "
diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py
new file mode 100644
index 000000000..e836bb914
--- /dev/null
+++ b/netbox/virtualization/migrations/0030_cluster_status.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.4 on 2022-05-19 19:36
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0029_created_datetimefield'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='cluster',
+ name='status',
+ field=models.CharField(default='active', max_length=50),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
new file mode 100644
index 000000000..85ea24455
--- /dev/null
+++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py
@@ -0,0 +1,28 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0153_created_datetimefield'),
+ ('virtualization', '0030_cluster_status'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='site',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'),
+ ),
+ migrations.AddField(
+ model_name='virtualmachine',
+ name='device',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'),
+ ),
+ migrations.AlterField(
+ model_name='virtualmachine',
+ name='cluster',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'),
+ ),
+ ]
diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
new file mode 100644
index 000000000..e9c52bfde
--- /dev/null
+++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py
@@ -0,0 +1,27 @@
+from django.db import migrations
+
+
+def update_virtualmachines_site(apps, schema_editor):
+ """
+ Automatically set the site for all virtual machines.
+ """
+ VirtualMachine = apps.get_model('virtualization', 'VirtualMachine')
+
+ virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False)
+ for vm in virtual_machines:
+ vm.site = vm.cluster.site
+ VirtualMachine.objects.bulk_update(virtual_machines, ['site'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0031_virtualmachine_site_device'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=update_virtualmachines_site,
+ reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 586bb8a9e..02560a962 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -119,6 +119,11 @@ class Cluster(NetBoxModel):
blank=True,
null=True
)
+ status = models.CharField(
+ max_length=50,
+ choices=ClusterStatusChoices,
+ default=ClusterStatusChoices.STATUS_ACTIVE
+ )
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
@@ -165,6 +170,9 @@ class Cluster(NetBoxModel):
def get_absolute_url(self):
return reverse('virtualization:cluster', args=[self.pk])
+ def get_status_color(self):
+ return ClusterStatusChoices.colors.get(self.status)
+
def clean(self):
super().clean()
@@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
"""
A virtual machine which runs inside a Cluster.
"""
+ site = models.ForeignKey(
+ to='dcim.Site',
+ on_delete=models.PROTECT,
+ related_name='virtual_machines',
+ blank=True,
+ null=True
+ )
cluster = models.ForeignKey(
to='virtualization.Cluster',
on_delete=models.PROTECT,
- related_name='virtual_machines'
+ related_name='virtual_machines',
+ blank=True,
+ null=True
+ )
+ device = models.ForeignKey(
+ to='dcim.Device',
+ on_delete=models.PROTECT,
+ related_name='virtual_machines',
+ blank=True,
+ null=True
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
objects = ConfigContextModelQuerySet.as_manager()
clone_fields = [
- 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
+ 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk',
]
class Meta:
@@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
def clean(self):
super().clean()
+ # Must be assigned to a site and/or cluster
+ if not self.site and not self.cluster:
+ raise ValidationError({
+ 'cluster': f'A virtual machine must be assigned to a site and/or cluster.'
+ })
+
+ # Validate site for cluster & device
+ if self.cluster and self.cluster.site != self.site:
+ raise ValidationError({
+ 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
+ })
+ if self.device and self.device.site != self.site:
+ raise ValidationError({
+ 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
+ })
+
+ # Validate assigned cluster device
+ if self.device and self.device not in self.cluster.devices.all():
+ raise ValidationError({
+ 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
+ })
+
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
@@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
else:
return None
- @property
- def site(self):
- return self.cluster.site
-
#
# Interfaces
diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py
index 893d3c641..dfcae052a 100644
--- a/netbox/virtualization/tables/clusters.py
+++ b/netbox/virtualization/tables/clusters.py
@@ -14,7 +14,9 @@ class ClusterTypeTable(NetBoxTable):
name = tables.Column(
linkify=True
)
- cluster_count = tables.Column(
+ cluster_count = columns.LinkedCountColumn(
+ viewname='virtualization:cluster_list',
+ url_params={'type_id': 'pk'},
verbose_name='Clusters'
)
tags = columns.TagColumn(
@@ -33,10 +35,12 @@ class ClusterGroupTable(NetBoxTable):
name = tables.Column(
linkify=True
)
- cluster_count = tables.Column(
+ cluster_count = columns.LinkedCountColumn(
+ viewname='virtualization:cluster_list',
+ url_params={'group_id': 'pk'},
verbose_name='Clusters'
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -62,6 +66,7 @@ class ClusterTable(NetBoxTable):
group = tables.Column(
linkify=True
)
+ status = columns.ChoiceFieldColumn()
tenant = tables.Column(
linkify=True
)
@@ -79,7 +84,7 @@ class ClusterTable(NetBoxTable):
verbose_name='VMs'
)
comments = columns.MarkdownColumn()
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
@@ -89,7 +94,7 @@ class ClusterTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = Cluster
fields = (
- 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts',
- 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count',
+ 'contacts', 'tags', 'created', 'last_updated',
)
- default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
+ default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count')
diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py
index d5017eb53..0fe2571b1 100644
--- a/netbox/virtualization/tables/virtualmachines.py
+++ b/netbox/virtualization/tables/virtualmachines.py
@@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable):
linkify=True
)
status = columns.ChoiceFieldColumn()
+ site = tables.Column(
+ linkify=True
+ )
cluster = tables.Column(
linkify=True
)
+ device = tables.Column(
+ linkify=True
+ )
role = columns.ColoredLabelColumn()
tenant = TenantColumn()
comments = columns.MarkdownColumn()
@@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VirtualMachine
fields = (
- 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
- 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory',
+ 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
- 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
+ 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
)
@@ -78,7 +84,7 @@ class VMInterfaceTable(BaseInterfaceTable):
vrf = tables.Column(
linkify=True
)
- contacts = tables.ManyToManyColumn(
+ contacts = columns.ManyToManyColumn(
linkify_item=True
)
tags = columns.TagColumn(
diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py
index f6c07fa54..b2ae68860 100644
--- a/netbox/virtualization/tests/test_api.py
+++ b/netbox/virtualization/tests/test_api.py
@@ -2,8 +2,10 @@ from django.urls import reverse
from rest_framework import status
from dcim.choices import InterfaceModeChoices
+from dcim.models import Site
from ipam.models import VLAN, VRF
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
+from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
model = Cluster
brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count']
bulk_update_data = {
+ 'status': 'offline',
'comments': 'New comment',
}
@@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]),
- Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]),
- Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]),
+ Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
+ Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
+ Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED),
)
Cluster.objects.bulk_create(clusters)
@@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase):
'name': 'Cluster 4',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 5',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
{
'name': 'Cluster 6',
'type': cluster_types[1].pk,
'group': cluster_groups[1].pk,
+ 'status': ClusterStatusChoices.STATUS_STAGING,
},
]
@@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1')
+ 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)
+
clusters = (
- Cluster(name='Cluster 1', type=clustertype, group=clustergroup),
- Cluster(name='Cluster 2', type=clustertype, group=clustergroup),
+ Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup),
+ Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup),
+ Cluster(name='Cluster 3', type=clustertype),
)
Cluster.objects.bulk_create(clusters)
+ device1 = create_test_device('device1', site=sites[0], cluster=clusters[0])
+ device2 = create_test_device('device2', site=sites[1], cluster=clusters[1])
+
virtual_machines = (
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}),
+ VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}),
+ VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}),
)
VirtualMachine.objects.bulk_create(virtual_machines)
cls.create_data = [
{
'name': 'Virtual Machine 4',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
+ 'device': device2.pk,
},
{
'name': 'Virtual Machine 5',
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
},
{
'name': 'Virtual Machine 6',
- 'cluster': clusters[1].pk,
+ 'site': sites[1].pk,
+ },
+ {
+ 'name': 'Virtual Machine 7',
+ 'cluster': clusters[2].pk,
},
]
diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py
index 9e264ac5c..d3ff12887 100644
--- a/netbox/virtualization/tests/test_filtersets.py
+++ b/netbox/virtualization/tests/test_filtersets.py
@@ -1,9 +1,9 @@
from django.test import TestCase
-from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup
+from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
from ipam.models import IPAddress, VRF
from tenancy.models import Tenant, TenantGroup
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.choices import *
from virtualization.filtersets import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
clusters = (
- Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]),
- Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]),
- Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]),
+ Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]),
+ Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]),
+ Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]),
)
Cluster.objects.bulk_create(clusters)
@@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_status(self):
+ params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_type(self):
types = ClusterType.objects.all()[:2]
params = {'type_id': [types[0].pk, types[1].pk]}
@@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
site_group.save()
sites = (
- Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]),
- Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]),
- Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]),
+ Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]),
+ Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]),
+ Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]),
)
Site.objects.bulk_create(sites)
@@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
)
DeviceRole.objects.bulk_create(roles)
+ devices = (
+ create_test_device('device1', cluster=clusters[0]),
+ create_test_device('device2', cluster=clusters[1]),
+ create_test_device('device3', cluster=clusters[2]),
+ )
+
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
@@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
vms = (
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}),
+ VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2),
+ VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3),
)
VirtualMachine.objects.bulk_create(vms)
@@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster': [clusters[0].name, clusters[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_device(self):
+ devices = Device.objects.all()[:2]
+ params = {'device_id': [devices[0].pk, devices[1].pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ params = {'device': [devices[0].name, devices[1].name]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py
index 3b4d73a30..df5816efa 100644
--- a/netbox/virtualization/tests/test_models.py
+++ b/netbox/virtualization/tests/test_models.py
@@ -1,21 +1,19 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
+from dcim.models import Site
from virtualization.models import *
from tenancy.models import Tenant
class VirtualMachineTestCase(TestCase):
- def setUp(self):
-
- cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1')
- self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
-
def test_vm_duplicate_name_per_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+ cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type)
vm1 = VirtualMachine(
- cluster=self.cluster,
+ cluster=cluster,
name='Test VM 1'
)
vm1.save()
@@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase):
# Two VMs assigned to the same Cluster and different Tenants should pass validation
vm2.full_clean()
vm2.save()
+
+ def test_vm_mismatched_site_cluster(self):
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
+ clusters = (
+ Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
+ Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
+ Cluster(name='Cluster 3', type=cluster_type, site=None),
+ )
+ Cluster.objects.bulk_create(clusters)
+
+ # VM with site only should pass
+ VirtualMachine(name='vm1', site=sites[0]).full_clean()
+
+ # VM with non-site cluster only should pass
+ VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
+
+ # VM with mismatched site & cluster should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
+
+ # VM with cluster site but no direct site should fail
+ with self.assertRaises(ValidationError):
+ VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 8edc14f00..01d4394f3 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -5,7 +5,7 @@ from netaddr import EUI
from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site
from ipam.models import VLAN, VRF
-from utilities.testing import ViewTestCases, create_tags
+from utilities.testing import ViewTestCases, create_tags, create_test_device
from virtualization.choices import *
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
ClusterType.objects.bulk_create(clustertypes)
Cluster.objects.bulk_create([
- Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
- Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
- Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]),
+ Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
+ Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
+ Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
@@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'name': 'Cluster X',
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
+ 'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'Some comments',
@@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- "name,type",
- "Cluster 4,Cluster Type 1",
- "Cluster 5,Cluster Type 1",
- "Cluster 6,Cluster Type 1",
+ "name,type,status",
+ "Cluster 4,Cluster Type 1,active",
+ "Cluster 5,Cluster Type 1,active",
+ "Cluster 6,Cluster Type 1,active",
)
cls.bulk_edit_data = {
'group': clustergroups[1].pk,
'type': clustertypes[1].pk,
+ 'status': ClusterStatusChoices.STATUS_OFFLINE,
'tenant': None,
'site': sites[1].pk,
'comments': 'New comments',
@@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
)
Platform.objects.bulk_create(platforms)
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
clusters = (
- Cluster(name='Cluster 1', type=clustertype),
- Cluster(name='Cluster 2', type=clustertype),
+ Cluster(name='Cluster 1', type=clustertype, site=sites[0]),
+ Cluster(name='Cluster 2', type=clustertype, site=sites[1]),
)
Cluster.objects.bulk_create(clusters)
+ devices = (
+ create_test_device('device1', site=sites[0], cluster=clusters[0]),
+ create_test_device('device2', site=sites[1], cluster=clusters[1]),
+ )
+
VirtualMachine.objects.bulk_create([
- VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
- VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
+ VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]),
])
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cluster': clusters[1].pk,
+ 'device': devices[1].pk,
+ 'site': sites[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'name': 'Virtual Machine X',
@@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
- "name,status,cluster",
- "Virtual Machine 4,active,Cluster 1",
- "Virtual Machine 5,active,Cluster 1",
- "Virtual Machine 6,active,Cluster 1",
+ "name,status,site,cluster,device",
+ "Virtual Machine 4,active,Site 1,Cluster 1,device1",
+ "Virtual Machine 5,active,Site 1,Cluster 1,device1",
+ "Virtual Machine 6,active,Site 1,Cluster 1,",
)
cls.bulk_edit_data = {
+ 'site': sites[1].pk,
'cluster': clusters[1].pk,
+ 'device': devices[1].pk,
'tenant': None,
'platform': platforms[1].pk,
'status': VirtualMachineStatusChoices.STATUS_STAGED,
@@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site)
virtualmachines = (
- VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole),
- VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole),
+ VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole),
)
VirtualMachine.objects.bulk_create(virtualmachines)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 850cb6388..0b593289b 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -39,7 +39,7 @@ class ClusterTypeView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
- clusters_table = tables.ClusterTable(clusters, exclude=('type',))
+ clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('type',))
clusters_table.configure(request)
return {
@@ -101,7 +101,7 @@ class ClusterGroupView(generic.ObjectView):
device_count=count_related(Device, 'cluster'),
vm_count=count_related(VirtualMachine, 'cluster')
)
- clusters_table = tables.ClusterTable(clusters, exclude=('group',))
+ clusters_table = tables.ClusterTable(clusters, user=request.user, exclude=('group',))
clusters_table.configure(request)
return {
diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py
index 6d7dc84a9..d1012ba59 100644
--- a/netbox/wireless/forms/models.py
+++ b/netbox/wireless/forms/models.py
@@ -105,6 +105,9 @@ class WirelessLinkForm(NetBoxModelForm):
)
location_a = DynamicModelChoiceField(
queryset=Location.objects.all(),
+ query_params={
+ 'site_id': '$site_a',
+ },
required=False,
label='Location',
initial_params={
@@ -142,6 +145,9 @@ class WirelessLinkForm(NetBoxModelForm):
)
location_b = DynamicModelChoiceField(
queryset=Location.objects.all(),
+ query_params={
+ 'site_id': '$site_b',
+ },
required=False,
label='Location',
initial_params={
diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py
index eee7fe1ed..988aa1b6d 100644
--- a/netbox/wireless/views.py
+++ b/netbox/wireless/views.py
@@ -29,7 +29,7 @@ class WirelessLANGroupView(generic.ObjectView):
wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter(
group=instance
)
- wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',))
+ wirelesslans_table = tables.WirelessLANTable(wirelesslans, user=request.user, exclude=('group',))
wirelesslans_table.configure(request)
return {
@@ -97,7 +97,7 @@ class WirelessLANView(generic.ObjectView):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
- interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
+ interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces, user=request.user)
interfaces_table.configure(request)
return {
diff --git a/requirements.txt b/requirements.txt
index 35867410b..1def8e23e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
Django==4.0.4
-django-cors-headers==3.11.0
+django-cors-headers==3.12.0
django-debug-toolbar==3.2.4
django-filter==21.1
django-graphiql-debug-toolbar==0.2.0
@@ -7,6 +7,7 @@ django-mptt==0.13.4
django-pglocks==1.0.4
django-prometheus==2.2.0
django-redis==5.2.0
+django-rich==1.4.0
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==2.1.0
@@ -15,15 +16,16 @@ djangorestframework==3.13.1
drf-yasg[validation]==1.20.0
graphene-django==2.15.0
gunicorn==20.1.0
-Jinja2==3.0.3
-Markdown==3.3.6
+Jinja2==3.1.2
+Markdown==3.3.7
markdown-include==0.6.0
-mkdocs-material==8.2.9
-mkdocstrings==0.17.0
+mkdocs-material==8.2.16
+mkdocstrings[python-legacy]==0.19.0
netaddr==0.8.0
-Pillow==9.1.0
+Pillow==9.1.1
psycopg2-binary==2.9.3
PyYAML==6.0
+sentry-sdk==1.5.12
social-auth-app-django==5.0.0
social-auth-core==4.2.0
svgwrite==1.4.2
diff --git a/upgrade.sh b/upgrade.sh
index 61e6106cd..161d65e32 100755
--- a/upgrade.sh
+++ b/upgrade.sh
@@ -108,6 +108,11 @@ COMMAND="python3 netbox/manage.py clearsessions"
echo "Removing expired user sessions ($COMMAND)..."
eval $COMMAND || exit 1
+# Clear the cache
+COMMAND="python3 netbox/manage.py clearcache"
+echo "Clearing the cache ($COMMAND)..."
+eval $COMMAND || exit 1
+
if [ -v WARN_MISSING_VENV ]; then
echo "--------------------------------------------------------------------"
echo "WARNING: No existing virtual environment was detected. A new one has"