Closes #8423: Allow assigning Service to FHRP Group, in addition to Device and VirtualMachine (#19005)

This commit is contained in:
Jason Novinger 2025-04-11 09:27:31 -05:00 committed by GitHub
parent fc0acb020f
commit f96df73093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 537 additions and 140 deletions

View File

@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s
## Fields ## Fields
### Parent
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
!!! note "Changed in NetBox v4.3"
Previously, `parent` was a property that pointed to either a Device or Virtual Machine. With the capability to assign services to FHRP groups, this is a unified in a concrete field.
### Name ### Name
A service or protocol name. A service or protocol name.

View File

@ -3,7 +3,7 @@ import yaml
from functools import cached_property 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.exceptions import ValidationError
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -609,6 +609,12 @@ class Device(
null=True, null=True,
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") 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='device',
)
# Counter fields # Counter fields
console_port_count = CounterCacheField( console_port_count = CounterCacheField(

View File

@ -1,9 +1,13 @@
from dcim.api.serializers_.devices import DeviceSerializer from django.contrib.contenttypes.models import ContentType
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from ipam.choices import * from ipam.choices import *
from ipam.constants import SERVICE_ASSIGNMENT_MODELS
from ipam.models import IPAddress, Service, ServiceTemplate from ipam.models import IPAddress, Service, ServiceTemplate
from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer from utilities.api import get_serializer_for_model
from .ip import IPAddressSerializer from .ip import IPAddressSerializer
__all__ = ( __all__ = (
@ -25,8 +29,6 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
class ServiceSerializer(NetBoxModelSerializer): class ServiceSerializer(NetBoxModelSerializer):
device = DeviceSerializer(nested=True, required=False, allow_null=True)
virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
ipaddresses = SerializedPKRelatedField( ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
@ -35,11 +37,24 @@ class ServiceSerializer(NetBoxModelSerializer):
required=False, required=False,
many=True many=True
) )
parent_object_type = ContentTypeField(
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS)
)
parent = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'id', 'url', 'display_url', 'display', 'parent_object_type', 'parent_object_id', 'parent', 'name',
'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description') 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

View File

@ -83,6 +83,12 @@ VLANGROUP_SCOPE_TYPES = (
# Services # 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 # 16-bit port number
SERVICE_PORT_MIN = 1 SERVICE_PORT_MIN = 1
SERVICE_PORT_MAX = 65535 SERVICE_PORT_MAX = 65535

View File

@ -1150,26 +1150,36 @@ class ServiceTemplateFilterSet(NetBoxModelFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet): class ServiceFilterSet(NetBoxModelFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter( device = MultiValueCharFilter(
queryset=Device.objects.all(), method='filter_device',
label=_('Device (ID)'), field_name='name',
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label=_('Device (name)'), label=_('Device (name)'),
) )
virtual_machine_id = django_filters.ModelMultipleChoiceFilter( device_id = MultiValueNumberFilter(
queryset=VirtualMachine.objects.all(), 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)'), label=_('Virtual machine (ID)'),
) )
virtual_machine = django_filters.ModelMultipleChoiceFilter( fhrpgroup = MultiValueCharFilter(
field_name='virtual_machine__name', method='filter_fhrp_group',
queryset=VirtualMachine.objects.all(), field_name='name',
to_field_name='name', label=_('FHRP Group (name)'),
label=_('Virtual machine (name)'), )
fhrpgroup_id = MultiValueNumberFilter(
method='filter_fhrp_group',
field_name='pk',
label=_('FHRP Group (ID)'),
) )
ip_address_id = django_filters.ModelMultipleChoiceFilter( ip_address_id = django_filters.ModelMultipleChoiceFilter(
field_name='ipaddresses', field_name='ipaddresses',
@ -1189,7 +1199,7 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
class Meta: class Meta:
model = Service model = Service
fields = ('id', 'name', 'protocol', 'description') fields = ('id', 'name', 'protocol', 'description', 'parent_object_type', 'parent_object_id')
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -1197,6 +1207,33 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
qs_filter = Q(name__icontains=value) | Q(description__icontains=value) qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
return queryset.filter(qs_filter) 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): class PrimaryIPFilterSet(django_filters.FilterSet):
""" """

View File

@ -559,19 +559,21 @@ class ServiceTemplateImportForm(NetBoxModelImportForm):
class ServiceImportForm(NetBoxModelImportForm): class ServiceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( parent_object_type = CSVContentTypeField(
label=_('Device'), queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
required=True,
label=_('Parent type (app & model)')
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
to_field_name='name', to_field_name='name',
help_text=_('Required if not assigned to a VM') help_text=_('Parent object name')
) )
virtual_machine = CSVModelChoiceField( parent_object_id = forms.IntegerField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
required=False, required=False,
to_field_name='name', help_text=_('Parent object ID'),
help_text=_('Required if not assigned to a device')
) )
protocol = CSVChoiceField( protocol = CSVChoiceField(
label=_('Protocol'), label=_('Protocol'),
@ -588,15 +590,52 @@ class ServiceImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Service model = Service
fields = ( fields = (
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
) )
def clean_ipaddresses(self): def __init__(self, data=None, *args, **kwargs):
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') super().__init__(data, *args, **kwargs)
for ip_address in self.cleaned_data['ipaddresses']:
# Limit parent queryset by assigned parent object type
if data:
match data.get('parent_object_type'):
case 'dcim.device':
self.fields['parent'].queryset = Device.objects.all()
case 'ipam.fhrpgroup':
self.fields['parent'].queryset = FHRPGroup.objects.all()
case 'virtualization.virtualmachine':
self.fields['parent'].queryset = VirtualMachine.objects.all()
def save(self, *args, **kwargs):
if (parent := self.cleaned_data.get('parent')):
self.instance.parent = parent
return super().save(*args, **kwargs)
def clean(self):
super().clean()
if (parent_ct := self.cleaned_data.get('parent_object_type')):
if (parent := self.cleaned_data.get('parent')):
self.cleaned_data['parent_object_id'] = parent.pk
elif (parent_id := self.cleaned_data.get('parent_object_id')):
parent = parent_ct.model_class().objects.filter(id=parent_id).first()
self.cleaned_data['parent'] = parent
else:
# If a parent object type is passed and we've made it here, then raise a validation
# error since an associated parent object or parent object id has not been passed
raise forms.ValidationError(
_("One of parent or parent_object_id must be included with parent_object_type")
)
# making sure parent is defined. In cases where an import is resulting in an update, the
# import data might not include the parent object and so the above logic might not be
# triggered
parent = self.cleaned_data.get('parent')
for ip_address in self.cleaned_data.get('ipaddresses', []):
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent: if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
raise forms.ValidationError( raise forms.ValidationError(
_("{ip} is not assigned to this device/VM.").format(ip=ip_address) _("{ip} is not assigned to this parent.").format(ip=ip_address)
) )
return self.cleaned_data['ipaddresses'] return self.cleaned_data

View File

@ -612,7 +612,7 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('protocol', 'port', name=_('Attributes')), FieldSet('protocol', 'port', name=_('Attributes')),
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')), FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
@ -625,4 +625,9 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
required=False, required=False,
label=_('Virtual Machine'), label=_('Virtual Machine'),
) )
fhrpgroup_id = DynamicModelMultipleChoiceField(
queryset=FHRPGroup.objects.all(),
required=False,
label=_('FHRP Group'),
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -21,7 +21,7 @@ from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, T
from utilities.forms.utils import get_field_value from utilities.forms.utils import get_field_value
from utilities.forms.widgets import DatePicker, HTMXSelect from utilities.forms.widgets import DatePicker, HTMXSelect
from utilities.templatetags.builtins.filters import bettertitle from utilities.templatetags.builtins.filters import bettertitle
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VMInterface
__all__ = ( __all__ = (
'AggregateForm', 'AggregateForm',
@ -759,16 +759,17 @@ class ServiceTemplateForm(NetBoxModelForm):
class ServiceForm(NetBoxModelForm): class ServiceForm(NetBoxModelForm):
device = DynamicModelChoiceField( parent_object_type = ContentTypeChoiceField(
label=_('Device'), queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
queryset=Device.objects.all(), widget=HTMXSelect(),
required=False, required=True,
selector=True label=_('Parent type')
) )
virtual_machine = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Virtual machine'), label=_('Parent'),
queryset=VirtualMachine.objects.all(), queryset=Device.objects.none(), # Initial queryset
required=False, required=True,
disabled=True,
selector=True selector=True
) )
ports = NumericArrayField( ports = NumericArrayField(
@ -792,11 +793,7 @@ class ServiceForm(NetBoxModelForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
TabbedGroups( 'parent_object_type', 'parent', 'name',
FieldSet('device', name=_('Device')),
FieldSet('virtual_machine', name=_('Virtual Machine')),
),
'name',
InlineFields('protocol', 'ports', label=_('Port(s)')), InlineFields('protocol', 'ports', label=_('Port(s)')),
'ipaddresses', 'description', 'tags', name=_('Service') 'ipaddresses', 'description', 'tags', name=_('Service')
), ),
@ -805,9 +802,38 @@ class ServiceForm(NetBoxModelForm):
class Meta: class Meta:
model = Service model = Service
fields = [ fields = [
'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags',
'parent_object_type',
] ]
def __init__(self, *args, **kwargs):
initial = kwargs.get('initial', {}).copy()
if (instance := kwargs.get('instance', None)) and instance.parent:
initial['parent'] = instance.parent
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if (parent_object_type_id := get_field_value(self, 'parent_object_type')):
try:
parent_type = ContentType.objects.get(pk=parent_object_type_id)
model = parent_type.model_class()
self.fields['parent'].queryset = model.objects.all()
self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower
self.fields['parent'].disabled = False
self.fields['parent'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and parent_object_type_id != self.instance.parent_object_type_id:
self.initial['parent'] = None
def clean(self):
super().clean()
self.instance.parent = self.cleaned_data.get('parent')
class ServiceCreateForm(ServiceForm): class ServiceCreateForm(ServiceForm):
service_template = DynamicModelChoiceField( service_template = DynamicModelChoiceField(
@ -818,10 +844,7 @@ class ServiceCreateForm(ServiceForm):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
TabbedGroups( 'parent_object_type', 'parent',
FieldSet('device', name=_('Device')),
FieldSet('virtual_machine', name=_('Virtual Machine')),
),
TabbedGroups( TabbedGroups(
FieldSet('service_template', name=_('From Template')), FieldSet('service_template', name=_('From Template')),
FieldSet('name', 'protocol', 'ports', name=_('Custom')), FieldSet('name', 'protocol', 'ports', name=_('Custom')),
@ -832,8 +855,8 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta): class Meta(ServiceForm.Meta):
fields = [ fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
'comments', 'tags', 'comments', 'tags', 'parent_object_type',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@ -19,8 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
from core.graphql.filters import ContentTypeFilter from core.graphql.filters import ContentTypeFilter
from dcim.graphql.filters import DeviceFilter, SiteFilter from dcim.graphql.filters import SiteFilter
from virtualization.graphql.filters import VirtualMachineFilter
from vpn.graphql.filters import L2VPNFilter from vpn.graphql.filters import L2VPNFilter
from .enums import * from .enums import *
@ -216,16 +215,14 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter(models.Service, lookups=True) @strawberry_django.filter(models.Service, lookups=True)
class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin): class ServiceFilter(ContactFilterMixin, ServiceBaseFilterMixin, PrimaryModelFilterMixin):
device: Annotated['DeviceFilter', strawberry.lazy('dcim.graphql.filters')] | None = strawberry_django.filter_field()
device_id: ID | None = strawberry_django.filter_field()
virtual_machine: Annotated['VirtualMachineFilter', strawberry.lazy('virtualization.graphql.filters')] | None = (
strawberry_django.filter_field()
)
virtual_machine_id: ID | None = strawberry_django.filter_field()
name: FilterLookup[str] | None = strawberry_django.filter_field() name: FilterLookup[str] | None = strawberry_django.filter_field()
ipaddresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = ( ipaddresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
strawberry_django.filter_field() strawberry_django.filter_field()
) )
parent_object_type: Annotated['ContentTypeFilter', strawberry.lazy('core.graphql.filters')] | None = (
strawberry_django.filter_field()
)
parent_object_id: ID | None = strawberry_django.filter_field()
@strawberry_django.filter(models.ServiceTemplate, lookups=True) @strawberry_django.filter(models.ServiceTemplate, lookups=True)

View File

@ -241,17 +241,22 @@ class RouteTargetType(NetBoxObjectType):
@strawberry_django.type( @strawberry_django.type(
models.Service, models.Service,
fields='__all__', exclude=('parent_object_type', 'parent_object_id'),
filters=ServiceFilter, filters=ServiceFilter,
pagination=True pagination=True
) )
class ServiceType(NetBoxObjectType, ContactsMixin): class ServiceType(NetBoxObjectType, ContactsMixin):
ports: List[int] ports: List[int]
device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None
virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None
ipaddresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] ipaddresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]]
@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( @strawberry_django.type(
models.ServiceTemplate, models.ServiceTemplate,

View File

@ -0,0 +1,29 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0078_iprange_mark_utilized'),
]
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,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='+',
to='contenttypes.contenttype'
),
),
]

View File

@ -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', '0079_add_service_fhrp_group_parent_gfk'),
('virtualization', '0048_populate_mac_addresses'),
]
operations = [
migrations.RunPython(
populate_service_parent_gfk,
reverse_code=repopulate_device_and_virtualmachine_relations,
)
]

View File

@ -0,0 +1,39 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0126_exporttemplate_file_name'),
('ipam', '0080_populate_service_parent'),
]
operations = [
migrations.RemoveField(
model_name='service',
name='device',
),
migrations.RemoveField(
model_name='service',
name='virtual_machine',
),
migrations.AlterField(
model_name='service',
name='parent_object_id',
field=models.PositiveBigIntegerField(),
),
migrations.AlterField(
model_name='service',
name='parent_object_type',
field=models.ForeignKey(
on_delete=models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'
),
),
migrations.AddIndex(
model_name='service',
index=models.Index(
fields=['parent_object_type', 'parent_object_id'], name='ipam_servic_parent__563d2b_idx'
),
),
]

View File

@ -48,6 +48,12 @@ class FHRPGroup(PrimaryModel):
object_id_field='assigned_object_id', object_id_field='assigned_object_id',
related_query_name='fhrpgroup' 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') clone_fields = ('protocol', 'auth_type', 'auth_key', 'description')

View File

@ -1,5 +1,5 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -64,21 +64,17 @@ 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 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. optionally be tied to one or more specific IPAddresses belonging to its parent.
""" """
device = models.ForeignKey( parent_object_type = models.ForeignKey(
to='dcim.Device', to='contenttypes.ContentType',
on_delete=models.CASCADE, on_delete=models.PROTECT,
related_name='services', related_name='+',
verbose_name=_('device'),
null=True,
blank=True
) )
virtual_machine = models.ForeignKey( parent_object_id = models.PositiveBigIntegerField()
to='virtualization.VirtualMachine', parent = GenericForeignKey(
on_delete=models.CASCADE, ct_field='parent_object_type',
related_name='services', fk_field='parent_object_id'
null=True,
blank=True
) )
name = models.CharField( name = models.CharField(
max_length=100, max_length=100,
verbose_name=_('name') verbose_name=_('name')
@ -91,22 +87,12 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel):
help_text=_("The specific IP addresses (if any) to which this service is bound") 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: class Meta:
indexes = (
models.Index(fields=('parent_object_type', 'parent_object_id')),
)
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
verbose_name = _('service') verbose_name = _('service')
verbose_name_plural = _('services') 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."))

View File

@ -123,7 +123,7 @@ class ServiceIndex(SearchIndex):
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )
display_attrs = ('device', 'virtual_machine', 'description') display_attrs = ('parent', 'description')
@register_search @register_search

View File

@ -1198,27 +1198,30 @@ class ServiceTest(APIViewTestCases.APIViewTestCase):
Device.objects.bulk_create(devices) Device.objects.bulk_create(devices)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), Service(parent=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(device=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]), Service(parent=devices[0], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2]),
Service(device=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]), Service(parent=devices[0], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
cls.create_data = [ cls.create_data = [
{ {
'device': devices[1].pk, 'parent_object_id': devices[1].pk,
'parent_object_type': 'dcim.device',
'name': 'Service 4', 'name': 'Service 4',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'ports': [4], 'ports': [4],
}, },
{ {
'device': devices[1].pk, 'parent_object_id': devices[1].pk,
'parent_object_type': 'dcim.device',
'name': 'Service 5', 'name': 'Service 5',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'ports': [5], 'ports': [5],
}, },
{ {
'device': devices[1].pk, 'parent_object_id': devices[1].pk,
'parent_object_type': 'dcim.device',
'name': 'Service 6', 'name': 'Service 6',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'ports': [6], 'ports': [6],

View File

@ -1258,9 +1258,24 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
IPAddress.objects.bulk_create(ipaddresses) IPAddress.objects.bulk_create(ipaddresses)
services = ( services = (
Service(name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), Service(
Service(name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), parent=devices[0],
Service(name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]), name='Service 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1],
),
Service(
parent=devices[1],
name='Service 2',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1],
),
Service(
parent=devices[2],
name='Service 3',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1],
),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
services[0].ipaddresses.add(ipaddresses[0]) services[0].ipaddresses.add(ipaddresses[0])
@ -2329,41 +2344,57 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine(name='Virtual Machine 3', cluster=cluster), VirtualMachine(name='Virtual Machine 3', cluster=cluster),
) )
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
fhrp_group = FHRPGroup.objects.create(
name='telnet',
protocol=FHRPGroupProtocolChoices.PROTOCOL_CARP,
group_id=101,
)
services = ( services = (
Service( Service(
device=devices[0], parent=devices[0],
name='Service 1', name='Service 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP, protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1001], ports=[1001],
description='foobar1', description='foobar1',
), ),
Service( Service(
device=devices[1], parent=devices[1],
name='Service 2', name='Service 2',
protocol=ServiceProtocolChoices.PROTOCOL_TCP, protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1002], ports=[1002],
description='foobar2', description='foobar2',
), ),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
Service( Service(
virtual_machine=virtual_machines[0], parent=devices[2],
name='Service 3',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[1003]
),
Service(
parent=virtual_machines[0],
name='Service 4', name='Service 4',
protocol=ServiceProtocolChoices.PROTOCOL_TCP, protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2001], ports=[2001],
), ),
Service( Service(
virtual_machine=virtual_machines[1], parent=virtual_machines[1],
name='Service 5', name='Service 5',
protocol=ServiceProtocolChoices.PROTOCOL_TCP, protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2002], ports=[2002],
), ),
Service( Service(
virtual_machine=virtual_machines[2], parent=virtual_machines[2],
name='Service 6', name='Service 6',
protocol=ServiceProtocolChoices.PROTOCOL_UDP, protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[2003], ports=[2003],
), ),
Service(
parent=fhrp_group,
name='Service 7',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[2004],
),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
services[0].ipaddresses.add(ip_addresses[0]) services[0].ipaddresses.add(ip_addresses[0])
@ -2404,6 +2435,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'virtual_machine': [vms[0].name, vms[1].name]} params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_fhrp_group(self):
fhrp_group = FHRPGroup.objects.get()
params = {'fhrpgroup_id': [fhrp_group.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'fhrpgroup': [fhrp_group.name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_ip_address(self): def test_ip_address(self):
ips = IPAddress.objects.all()[:2] ips = IPAddress.objects.all()[:2]
params = {'ip_address_id': [ips[0].pk, ips[1].pk]} params = {'ip_address_id': [ips[0].pk, ips[1].pk]}

View File

@ -5,11 +5,14 @@ from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork from netaddr import IPNetwork
from core.models import ObjectType
from dcim.constants import InterfaceTypeChoices from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
from ipam.choices import * from ipam.choices import *
from ipam.models import * from ipam.models import *
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags from utilities.testing import ViewTestCases, create_tags
@ -1053,6 +1056,8 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service model = Service
# TODO, related to #9816, cannot validate GFK
validation_excluded_fields = ('device',)
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@ -1065,9 +1070,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL) interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
services = ( services = (
Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
Service(device=device, name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), Service(parent=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 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
) )
Service.objects.bulk_create(services) Service.objects.bulk_create(services)
@ -1080,8 +1085,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie') tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = { cls.form_data = {
'device': device.pk, 'parent_object_type': ContentType.objects.get_for_model(Device).pk,
'virtual_machine': None, 'parent': device.pk,
'name': 'Service X', 'name': 'Service X',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP, 'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'ports': '104,105', 'ports': '104,105',
@ -1091,10 +1096,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"device,name,protocol,ports,ipaddresses,description", "parent_object_type,parent,name,protocol,ports,ipaddresses,description",
"Device 1,Service 1,tcp,1,192.0.2.1/24,First service", "dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
"Device 1,Service 2,tcp,2,192.0.2.2/24,Second service", "dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
"Device 1,Service 3,udp,3,,Third service", "dcim.device,Device 1,Service 3,udp,3,,Third service",
) )
cls.csv_update_data = ( cls.csv_update_data = (
@ -1110,6 +1115,66 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', 'description': 'New description',
} }
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_unassigned_ip_addresses(self):
device = Device.objects.first()
addr = IPAddress.objects.create(address='192.0.2.4/24')
csv_data = (
"parent_object_type,parent_object_id,name,protocol,ports,ipaddresses,description",
f"dcim.device,{device.pk},Service 11,tcp,10,{addr.address},Eleventh service",
)
initial_count = self._get_queryset().count()
data = {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
obj_perm = ObjectPermission.objects.create(name='Test permission', actions=['add'])
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Test POST with permission
response = self.client.post(self._get_url('bulk_import'), data)
self.assertHttpStatus(response, 200)
form_errors = response.context['form'].errors
self.assertEqual(len(form_errors), 1)
self.assertIn(addr.address, form_errors['__all__'][0])
self.assertEqual(self._get_queryset().count(), initial_count)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
def test_alternate_csv_import(self):
device = Device.objects.first()
interface = device.interfaces.first()
addr = IPAddress.objects.create(assigned_object=interface, address='192.0.2.3/24')
csv_data = (
"parent_object_type,parent_object_id,name,protocol,ports,ipaddresses,description",
f"dcim.device,{device.pk},Service 11,tcp,10,{addr.address},Eleventh service",
)
initial_count = self._get_queryset().count()
data = {
'data': '\n'.join(csv_data),
'format': ImportFormatChoices.CSV,
'csv_delimiter': CSVDelimiterChoices.AUTO,
}
# Assign model-level permission
obj_perm = ObjectPermission.objects.create(name='Test permission', actions=['add'])
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Test POST with permission
response = self.client.post(self._get_url('bulk_import'), data)
if response.status_code != 302:
self.assertEqual(response.context['form'].errors, {}) # debugging aid
self.assertHttpStatus(response, 302)
self.assertEqual(self._get_queryset().count(), initial_count + len(csv_data) - 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_create_from_template(self): def test_create_from_template(self):
self.add_permissions('ipam.add_service') self.add_permissions('ipam.add_service')
@ -1125,13 +1190,15 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
request = { request = {
'path': self._get_url('add'), 'path': self._get_url('add'),
'data': { 'data': {
'device': device.pk, 'parent_object_type': ContentType.objects.get_for_model(Device).pk,
'parent': device.pk,
'service_template': service_template.pk, 'service_template': service_template.pk,
}, },
} }
self.assertHttpStatus(self.client.post(**request), 302) self.assertHttpStatus(self.client.post(**request), 302)
instance = self._get_queryset().order_by('pk').last() 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.name, service_template.name)
self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.protocol, service_template.protocol)
self.assertEqual(instance.ports, service_template.ports) self.assertEqual(instance.ports, service_template.ports)

View File

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import Provider from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable from ipam.tables import VLANTranslationRuleTable
from netbox.views import generic from netbox.views import generic
from utilities.query import count_related from utilities.query import count_related
@ -16,7 +16,7 @@ from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface from virtualization.models import VirtualMachine, VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import PrefixStatusChoices from .choices import PrefixStatusChoices
from .constants import * from .constants import *
@ -1161,7 +1161,7 @@ class FHRPGroupListView(generic.ObjectListView):
@register_model_view(FHRPGroup) @register_model_view(FHRPGroup)
class FHRPGroupView(generic.ObjectView): class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = FHRPGroup.objects.all() queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
@ -1173,6 +1173,18 @@ class FHRPGroupView(generic.ObjectView):
members_table.columns.hide('group') members_table.columns.hide('group')
return { return {
'related_models': self.get_related_models(
request, instance,
extra=(
(
Service.objects.restrict(request.user, 'view').filter(
parent_object_type=ContentType.objects.get_for_model(FHRPGroup),
parent_object_id=instance.id,
),
'fhrpgroup_id'
),
),
),
'members_table': members_table, 'members_table': members_table,
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(), 'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
} }
@ -1409,7 +1421,7 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
@register_model_view(Service, 'list', path='', detail=False) @register_model_view(Service, 'list', path='', detail=False)
class ServiceListView(generic.ObjectListView): class ServiceListView(generic.ObjectListView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable table = tables.ServiceTable
@ -1419,6 +1431,18 @@ class ServiceListView(generic.ObjectListView):
class ServiceView(generic.ObjectView): class ServiceView(generic.ObjectView):
queryset = Service.objects.all() queryset = Service.objects.all()
def get_extra_context(self, request, instance):
context = {}
match instance.parent:
case Device():
context['breadcrumb_queryparam'] = 'device_id'
case VirtualMachine():
context['breadcrumb_queryparam'] = 'virtual_machine_id'
case FHRPGroup():
context['breadcrumb_queryparam'] = 'fhrpgroup_id'
return context
@register_model_view(Service, 'add', detail=False) @register_model_view(Service, 'add', detail=False)
class ServiceCreateView(generic.ObjectEditView): class ServiceCreateView(generic.ObjectEditView):
@ -1445,7 +1469,7 @@ class ServiceBulkImportView(generic.BulkImportView):
@register_model_view(Service, 'bulk_edit', path='edit', detail=False) @register_model_view(Service, 'bulk_edit', path='edit', detail=False)
class ServiceBulkEditView(generic.BulkEditView): class ServiceBulkEditView(generic.BulkEditView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable
form = forms.ServiceBulkEditForm form = forms.ServiceBulkEditForm
@ -1453,6 +1477,6 @@ class ServiceBulkEditView(generic.BulkEditView):
@register_model_view(Service, 'bulk_delete', path='delete', detail=False) @register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView): class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable table = tables.ServiceTable

View File

@ -58,6 +58,7 @@
</tr> </tr>
</table> </table>
</div> </div>
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>

View File

@ -7,10 +7,12 @@
{% block breadcrumbs %} {% block breadcrumbs %}
{{ block.super }} {{ block.super }}
{% if object.device %} {% if object.parent and breadcrumb_queryparam %}
<li class="breadcrumb-item"><a href="{% url 'ipam:service_list' %}?device_id={{ object.device.pk }}">{{ object.device }}</a></li> <li class="breadcrumb-item">
{% elif object.virtual_machine %} <a href="{% url 'ipam:service_list' %}?{{ breadcrumb_queryparam }}={{ object.parent.pk }}">
<li class="breadcrumb-item"><a href="{% url 'ipam:service_list' %}?virtual_machine_id={{ object.virtual_machine.pk }}">{{ object.virtual_machine }}</a></li> {{ object.parent }}
</a>
</li>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -126,6 +126,12 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
blank=True, blank=True,
max_length=50 max_length=50
) )
services = GenericRelation(
to='ipam.Service',
content_type_field='parent_object_type',
object_id_field='parent_object_id',
related_query_name='virtual_machine',
)
# Counter fields # Counter fields
interface_count = CounterCacheField( interface_count = CounterCacheField(