diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 716b2aaf6..130d24c42 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -3,7 +3,7 @@ import yaml from functools import cached_property -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator @@ -609,6 +609,12 @@ class Device( null=True, help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") ) + services = GenericRelation( + to='ipam.Service', + content_type_field='parent_object_type', + object_id_field='parent_object_id', + related_query_name='devices', + ) # Counter fields console_port_count = CounterCacheField( diff --git a/netbox/ipam/api/serializers_/services.py b/netbox/ipam/api/serializers_/services.py index 61b330d01..27d7b4445 100644 --- a/netbox/ipam/api/serializers_/services.py +++ b/netbox/ipam/api/serializers_/services.py @@ -1,9 +1,13 @@ -from dcim.api.serializers_.devices import DeviceSerializer +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from dcim.models import Device from ipam.choices import * -from ipam.models import IPAddress, Service, ServiceTemplate +from ipam.models import IPAddress, FHRPGroup, Service, ServiceTemplate from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer -from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer +from utilities.api import get_serializer_for_model +from virtualization.models import VirtualMachine from .ip import IPAddressSerializer __all__ = ( @@ -25,8 +29,9 @@ class ServiceTemplateSerializer(NetBoxModelSerializer): class ServiceSerializer(NetBoxModelSerializer): - device = DeviceSerializer(nested=True, required=False, allow_null=True) - virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True) + device = serializers.SerializerMethodField(read_only=True) + virtual_machine = serializers.SerializerMethodField(read_only=True) + fhrp_group = serializers.SerializerMethodField(read_only=True) protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), @@ -39,7 +44,34 @@ class ServiceSerializer(NetBoxModelSerializer): class Meta: model = Service fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', - 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display_url', 'display', 'device', 'virtual_machine', 'fhrp_group', 'name', + 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_parent(self, obj): + if obj.parent is None: + return None + serializer = get_serializer_for_model(obj.parent) + context = {'request': self.context['request']} + return serializer(obj.parent, nested=True, context=context).data + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_device(self, obj): + if isinstance(obj.parent, Device): + return self.get_parent(obj) + return None + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_virtual_machine(self, obj): + if isinstance(obj.parent, VirtualMachine): + return self.get_parent(obj) + return None + + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_fhrp_group(self, obj): + if isinstance(obj.parent, FHRPGroup): + return self.get_parent(obj) + return None diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 6dffd3287..947f1adea 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -83,6 +83,12 @@ VLANGROUP_SCOPE_TYPES = ( # Services # +SERVICE_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='device') | + Q(app_label='ipam', model='fhrpgroup') | + Q(app_label='virtualization', model='virtualmachine') +) + # 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index aaa4b6b1c..a628f0392 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1150,26 +1150,36 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) -class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet): - 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', +class ServiceFilterSet(NetBoxModelFilterSet): + device = MultiValueCharFilter( + method='filter_device', + field_name='name', label=_('Device (name)'), ) - virtual_machine_id = django_filters.ModelMultipleChoiceFilter( - queryset=VirtualMachine.objects.all(), + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', + label=_('Device (ID)'), + ) + virtual_machine = MultiValueCharFilter( + method='filter_virtual_machine', + field_name='name', + label=_('Virtual machine (name)'), + ) + virtual_machine_id = MultiValueNumberFilter( + method='filter_virtual_machine', + field_name='pk', label=_('Virtual machine (ID)'), ) - virtual_machine = django_filters.ModelMultipleChoiceFilter( - field_name='virtual_machine__name', - queryset=VirtualMachine.objects.all(), - to_field_name='name', - label=_('Virtual machine (name)'), + fhrp_group = MultiValueCharFilter( + method='filter_fhrp_group', + field_name='name', + label=_('FHRP Group (name)'), + ) + fhrp_group_id = MultiValueNumberFilter( + method='filter_fhrp_group', + field_name='pk', + label=_('FHRP Group (ID)'), ) ip_address_id = django_filters.ModelMultipleChoiceFilter( field_name='ipaddresses', @@ -1197,6 +1207,33 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet): qs_filter = Q(name__icontains=value) | Q(description__icontains=value) return queryset.filter(qs_filter) + def filter_device(self, queryset, name, value): + devices = Device.objects.filter(**{'{}__in'.format(name): value}) + if not devices.exists(): + return queryset.none() + service_ids = [] + for device in devices: + service_ids.extend(device.services.values_list('id', flat=True)) + return queryset.filter(id__in=service_ids) + + def filter_fhrp_group(self, queryset, name, value): + groups = FHRPGroup.objects.filter(**{'{}__in'.format(name): value}) + if not groups.exists(): + return queryset.none() + service_ids = [] + for group in groups: + service_ids.extend(group.services.values_list('id', flat=True)) + return queryset.filter(id__in=service_ids) + + def filter_virtual_machine(self, queryset, name, value): + virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value}) + if not virtual_machines.exists(): + return queryset.none() + service_ids = [] + for vm in virtual_machines: + service_ids.extend(vm.services.values_list('id', flat=True)) + return queryset.filter(id__in=service_ids) + class PrimaryIPFilterSet(django_filters.FilterSet): """ diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 37e7ec155..d98481781 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -763,13 +763,19 @@ class ServiceForm(NetBoxModelForm): label=_('Device'), queryset=Device.objects.all(), required=False, - selector=True + selector=True, ) virtual_machine = DynamicModelChoiceField( label=_('Virtual machine'), queryset=VirtualMachine.objects.all(), required=False, - selector=True + selector=True, + ) + fhrp_group = DynamicModelChoiceField( + label=_('FHRP Group'), + queryset=FHRPGroup.objects.all(), + required=False, + selector=True, ) ports = NumericArrayField( label=_('Ports'), @@ -795,6 +801,7 @@ class ServiceForm(NetBoxModelForm): TabbedGroups( FieldSet('device', name=_('Device')), FieldSet('virtual_machine', name=_('Virtual Machine')), + FieldSet('fhrp_group', name=_('FHRP Group')), ), 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), @@ -805,9 +812,42 @@ class ServiceForm(NetBoxModelForm): class Meta: model = Service fields = [ - 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', + 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance: + parent_type = type(instance.parent) + if parent_type is Device: + initial['device'] = instance.parent + elif parent_type is VirtualMachine: + initial['virtual_machine'] = instance.parent + elif parent_type is FHRPGroup: + initial['fhrp_group'] = instance.parent + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + selected_objects = [f for f in ('device', 'virtual_machine', 'fhrp_group') if self.cleaned_data[f]] + if len(selected_objects) > 1: + raise forms.ValidationError({ + field: _("A Service must be associated with exactly one device, virtual machine, or FHRP group.") + for field in selected_objects + }) + elif selected_objects: + self.instance.parent = self.cleaned_data[selected_objects[0]] + else: + raise forms.ValidationError({ + 'device': _("A service must be associated with a device, a virtual machine, or an FHRP group.") + }) + class ServiceCreateForm(ServiceForm): service_template = DynamicModelChoiceField( @@ -821,6 +861,7 @@ class ServiceCreateForm(ServiceForm): TabbedGroups( FieldSet('device', name=_('Device')), FieldSet('virtual_machine', name=_('Virtual Machine')), + FieldSet('fhrp_group', name=_('FHRP Group')), ), TabbedGroups( FieldSet('service_template', name=_('From Template')), @@ -832,7 +873,7 @@ class ServiceCreateForm(ServiceForm): class Meta(ServiceForm.Meta): fields = [ - 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', + 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', ] diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 7744445b3..7a0b75c26 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -5,10 +5,12 @@ import strawberry_django from circuits.graphql.types import ProviderType from dcim.graphql.types import SiteType +from dcim.models import Device from extras.graphql.mixins import ContactsMixin from ipam import models from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType +from virtualization.models import VirtualMachine from .filters import * from .mixins import IPAddressesMixin @@ -247,11 +249,43 @@ class RouteTargetType(NetBoxObjectType): ) class ServiceType(NetBoxObjectType, ContactsMixin): ports: List[int] - device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None - virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None - + # device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None + # virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None + # fhrp_group: Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')] | None ipaddresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] + @strawberry_django.field + def device(self) -> Annotated[Union[ + Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("ServiceAssignmentType")] | None: + if isinstance(self.parent, Device): + return self.parent + return None + + @strawberry_django.field + def virtual_machine(self) -> Annotated[Union[ + Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')], + ], strawberry.union("ServiceAssignmentType")] | None: + if isinstance(self.parent, VirtualMachine): + return self.parent + return None + + @strawberry_django.field + def fhrp_group(self) -> Annotated[Union[ + Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')], + ], strawberry.union("ServiceAssignmentType")] | None: + if isinstance(self.parent, models.FHRPGroup): + return self.parent + return None + + @strawberry_django.field + def parent(self) -> Annotated[Union[ + Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')], + Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')], + Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')], + ], strawberry.union("ServiceParentType")] | None: + return self.parent + @strawberry_django.type( models.ServiceTemplate, diff --git a/netbox/ipam/migrations/0078_service_parent_object_id_service_parent_object_type.py b/netbox/ipam/migrations/0078_service_parent_object_id_service_parent_object_type.py new file mode 100644 index 000000000..3828a8618 --- /dev/null +++ b/netbox/ipam/migrations/0078_service_parent_object_id_service_parent_object_type.py @@ -0,0 +1,37 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0077_vlangroup_tenant'), + ] + + operations = [ + migrations.AddField( + model_name='service', + name='parent_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='service', + name='parent_object_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q( + models.Q( + models.Q(('app_label', 'dcim'), ('model', 'device')), + models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')), + models.Q(('app_label', 'virtualization'), ('model', 'virtualmachine')), + _connector='OR' + ) + ), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='contenttypes.contenttype' + ), + ), + ] diff --git a/netbox/ipam/migrations/0079_populate_service_parent.py b/netbox/ipam/migrations/0079_populate_service_parent.py new file mode 100644 index 000000000..eb91aa444 --- /dev/null +++ b/netbox/ipam/migrations/0079_populate_service_parent.py @@ -0,0 +1,54 @@ +from django.db import migrations +from django.db.models import F + + +def populate_service_parent_gfk(apps, schema_config): + Service = apps.get_model('ipam', 'Service') + ContentType = apps.get_model('contenttypes', 'ContentType') + Device = apps.get_model('dcim', 'device') + VirtualMachine = apps.get_model('virtualization', 'virtualmachine') + + Service.objects.filter(device_id__isnull=False).update( + parent_object_type=ContentType.objects.get_for_model(Device), + parent_object_id=F('device_id'), + ) + + Service.objects.filter(virtual_machine_id__isnull=False).update( + parent_object_type=ContentType.objects.get_for_model(VirtualMachine), + parent_object_id=F('virtual_machine_id'), + ) + + +def repopulate_device_and_virtualmachine_relations(apps, schemaconfig): + Service = apps.get_model('ipam', 'Service') + ContentType = apps.get_model('contenttypes', 'ContentType') + Device = apps.get_model('dcim', 'device') + VirtualMachine = apps.get_model('virtualization', 'virtualmachine') + + Service.objects.filter( + parent_object_type=ContentType.objects.get_for_model(Device), + ).update( + device_id=F('parent_object_id') + ) + + Service.objects.filter( + parent_object_type=ContentType.objects.get_for_model(VirtualMachine), + ).update( + virtual_machine_id=F('parent_object_id') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0202_location_comments_region_comments_sitegroup_comments'), + ('ipam', '0078_service_parent_object_id_service_parent_object_type'), + ('virtualization', '0048_populate_mac_addresses'), + ] + + operations = [ + migrations.RunPython( + populate_service_parent_gfk, + reverse_code=repopulate_device_and_virtualmachine_relations, + ) + ] diff --git a/netbox/ipam/migrations/0080_remove_service_device_remove_service_virtual_machine.py b/netbox/ipam/migrations/0080_remove_service_device_remove_service_virtual_machine.py new file mode 100644 index 000000000..83bff5751 --- /dev/null +++ b/netbox/ipam/migrations/0080_remove_service_device_remove_service_virtual_machine.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0079_populate_service_parent'), + ] + + operations = [ + migrations.RemoveField( + model_name='service', + name='device', + ), + migrations.RemoveField( + model_name='service', + name='virtual_machine', + ), + ] diff --git a/netbox/ipam/migrations/0081_service_ipam_servic_parent__563d2b_idx.py b/netbox/ipam/migrations/0081_service_ipam_servic_parent__563d2b_idx.py new file mode 100644 index 000000000..125e5aaca --- /dev/null +++ b/netbox/ipam/migrations/0081_service_ipam_servic_parent__563d2b_idx.py @@ -0,0 +1,19 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0125_exporttemplate_file_name'), + ('ipam', '0080_remove_service_device_remove_service_virtual_machine'), + ] + + operations = [ + migrations.AddIndex( + model_name='service', + index=models.Index( + fields=['parent_object_type', 'parent_object_id'], name='ipam_servic_parent__563d2b_idx' + ), + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index f5982853e..63a04d4d1 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -48,6 +48,12 @@ class FHRPGroup(PrimaryModel): object_id_field='assigned_object_id', related_query_name='fhrpgroup' ) + services = GenericRelation( + to='ipam.Service', + content_type_field='parent_object_type', + object_id_field='parent_object_id', + related_query_name='fhrpgroup', + ) clone_fields = ('protocol', 'auth_type', 'auth_key', 'description') diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index bb4049781..f56fc705e 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -1,5 +1,5 @@ +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils.translation import gettext_lazy as _ @@ -64,21 +64,23 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel): A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='services', - verbose_name=_('device'), - null=True, - blank=True + parent_object_type = models.ForeignKey( + to='contenttypes.ContentType', + limit_choices_to=SERVICE_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='services', - null=True, - blank=True + parent_object_id = models.PositiveBigIntegerField( + blank=True, + null=True ) + parent = GenericForeignKey( + ct_field='parent_object_type', + fk_field='parent_object_id' + ) + name = models.CharField( max_length=100, verbose_name=_('name') @@ -91,22 +93,12 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel): help_text=_("The specific IP addresses (if any) to which this service is bound") ) - clone_fields = ['protocol', 'ports', 'description', 'device', 'virtual_machine', 'ipaddresses', ] + clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ] class Meta: + indexes = ( + models.Index(fields=('parent_object_type', 'parent_object_id')), + ) ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique verbose_name = _('service') verbose_name_plural = _('services') - - @property - def parent(self): - return self.device or self.virtual_machine - - def clean(self): - super().clean() - - # A Service must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError(_("A service cannot be associated with both a device and a virtual machine.")) - if not self.device and not self.virtual_machine: - raise ValidationError(_("A service must be associated with either a device or a virtual machine.")) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 345f39a51..0d7c22800 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1065,9 +1065,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) services = ( - Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), - Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), - Service(device=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), + Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), + Service(parent=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), + Service(parent=device, name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), ) Service.objects.bulk_create(services) @@ -1125,13 +1125,13 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): request = { 'path': self._get_url('add'), 'data': { - 'device': device.pk, + 'parent': device.pk, 'service_template': service_template.pk, }, } self.assertHttpStatus(self.client.post(**request), 302) instance = self._get_queryset().order_by('pk').last() - self.assertEqual(instance.device, device) + self.assertEqual(instance.parent, device) self.assertEqual(instance.name, service_template.name) self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.ports, service_template.ports) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index fc4406f7d..4c8bc5c69 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1409,7 +1409,7 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): @register_model_view(Service, 'list', path='', detail=False) class ServiceListView(generic.ObjectListView): - queryset = Service.objects.prefetch_related('device', 'virtual_machine') + queryset = Service.objects.prefetch_related('parent') filterset = filtersets.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable diff --git a/netbox/virtualization/models/virtualmachines.py b/netbox/virtualization/models/virtualmachines.py index 1922922e8..177d98b98 100644 --- a/netbox/virtualization/models/virtualmachines.py +++ b/netbox/virtualization/models/virtualmachines.py @@ -126,6 +126,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co blank=True, max_length=50 ) + services = GenericRelation( + to='ipam.Service', + content_type_field='parent_object_type', + object_id_field='parent_object_id', + related_query_name='virtualmachines', + ) # Counter fields interface_count = CounterCacheField(