Skip to content

Commit f96df73

Browse files
authored
Closes #8423: Allow assigning Service to FHRP Group, in addition to Device and VirtualMachine (#19005)
1 parent fc0acb0 commit f96df73

23 files changed

+535
-138
lines changed

docs/models/ipam/service.md

+9
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s
66

77
## Fields
88

9+
### Parent
10+
11+
The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md),
12+
[VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md).
13+
14+
!!! note "Changed in NetBox v4.3"
15+
16+
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.
17+
918
### Name
1019

1120
A service or protocol name.

netbox/dcim/models/devices.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from functools import cached_property
55

6-
from django.contrib.contenttypes.fields import GenericForeignKey
6+
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
77
from django.core.exceptions import ValidationError
88
from django.core.files.storage import default_storage
99
from django.core.validators import MaxValueValidator, MinValueValidator
@@ -609,6 +609,12 @@ class Device(
609609
null=True,
610610
help_text=_("GPS coordinate in decimal format (xx.yyyyyy)")
611611
)
612+
services = GenericRelation(
613+
to='ipam.Service',
614+
content_type_field='parent_object_type',
615+
object_id_field='parent_object_id',
616+
related_query_name='device',
617+
)
612618

613619
# Counter fields
614620
console_port_count = CounterCacheField(

netbox/ipam/api/serializers_/services.py

+22-7
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
from dcim.api.serializers_.devices import DeviceSerializer
1+
from django.contrib.contenttypes.models import ContentType
2+
from drf_spectacular.utils import extend_schema_field
3+
from rest_framework import serializers
4+
25
from ipam.choices import *
6+
from ipam.constants import SERVICE_ASSIGNMENT_MODELS
37
from ipam.models import IPAddress, Service, ServiceTemplate
4-
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
8+
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
59
from netbox.api.serializers import NetBoxModelSerializer
6-
from virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer
10+
from utilities.api import get_serializer_for_model
711
from .ip import IPAddressSerializer
812

913
__all__ = (
@@ -25,8 +29,6 @@ class Meta:
2529

2630

2731
class ServiceSerializer(NetBoxModelSerializer):
28-
device = DeviceSerializer(nested=True, required=False, allow_null=True)
29-
virtual_machine = VirtualMachineSerializer(nested=True, required=False, allow_null=True)
3032
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
3133
ipaddresses = SerializedPKRelatedField(
3234
queryset=IPAddress.objects.all(),
@@ -35,11 +37,24 @@ class ServiceSerializer(NetBoxModelSerializer):
3537
required=False,
3638
many=True
3739
)
40+
parent_object_type = ContentTypeField(
41+
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS)
42+
)
43+
parent = serializers.SerializerMethodField(read_only=True)
3844

3945
class Meta:
4046
model = Service
4147
fields = [
42-
'id', 'url', 'display_url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports',
43-
'ipaddresses', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
48+
'id', 'url', 'display_url', 'display', 'parent_object_type', 'parent_object_id', 'parent', 'name',
49+
'protocol', 'ports', 'ipaddresses', 'description', 'comments', 'tags', 'custom_fields',
50+
'created', 'last_updated',
4451
]
4552
brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
53+
54+
@extend_schema_field(serializers.JSONField(allow_null=True))
55+
def get_parent(self, obj):
56+
if obj.parent is None:
57+
return None
58+
serializer = get_serializer_for_model(obj.parent)
59+
context = {'request': self.context['request']}
60+
return serializer(obj.parent, nested=True, context=context).data

netbox/ipam/constants.py

+6
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@
8383
# Services
8484
#
8585

86+
SERVICE_ASSIGNMENT_MODELS = Q(
87+
Q(app_label='dcim', model='device') |
88+
Q(app_label='ipam', model='fhrpgroup') |
89+
Q(app_label='virtualization', model='virtualmachine')
90+
)
91+
8692
# 16-bit port number
8793
SERVICE_PORT_MIN = 1
8894
SERVICE_PORT_MAX = 65535

netbox/ipam/filtersets.py

+53-16
Original file line numberDiff line numberDiff line change
@@ -1150,26 +1150,36 @@ def search(self, queryset, name, value):
11501150
return queryset.filter(qs_filter)
11511151

11521152

1153-
class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
1154-
device_id = django_filters.ModelMultipleChoiceFilter(
1155-
queryset=Device.objects.all(),
1153+
class ServiceFilterSet(NetBoxModelFilterSet):
1154+
device = MultiValueCharFilter(
1155+
method='filter_device',
1156+
field_name='name',
1157+
label=_('Device (name)'),
1158+
)
1159+
device_id = MultiValueNumberFilter(
1160+
method='filter_device',
1161+
field_name='pk',
11561162
label=_('Device (ID)'),
11571163
)
1158-
device = django_filters.ModelMultipleChoiceFilter(
1159-
field_name='device__name',
1160-
queryset=Device.objects.all(),
1161-
to_field_name='name',
1162-
label=_('Device (name)'),
1164+
virtual_machine = MultiValueCharFilter(
1165+
method='filter_virtual_machine',
1166+
field_name='name',
1167+
label=_('Virtual machine (name)'),
11631168
)
1164-
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
1165-
queryset=VirtualMachine.objects.all(),
1169+
virtual_machine_id = MultiValueNumberFilter(
1170+
method='filter_virtual_machine',
1171+
field_name='pk',
11661172
label=_('Virtual machine (ID)'),
11671173
)
1168-
virtual_machine = django_filters.ModelMultipleChoiceFilter(
1169-
field_name='virtual_machine__name',
1170-
queryset=VirtualMachine.objects.all(),
1171-
to_field_name='name',
1172-
label=_('Virtual machine (name)'),
1174+
fhrpgroup = MultiValueCharFilter(
1175+
method='filter_fhrp_group',
1176+
field_name='name',
1177+
label=_('FHRP Group (name)'),
1178+
)
1179+
fhrpgroup_id = MultiValueNumberFilter(
1180+
method='filter_fhrp_group',
1181+
field_name='pk',
1182+
label=_('FHRP Group (ID)'),
11731183
)
11741184
ip_address_id = django_filters.ModelMultipleChoiceFilter(
11751185
field_name='ipaddresses',
@@ -1189,14 +1199,41 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
11891199

11901200
class Meta:
11911201
model = Service
1192-
fields = ('id', 'name', 'protocol', 'description')
1202+
fields = ('id', 'name', 'protocol', 'description', 'parent_object_type', 'parent_object_id')
11931203

11941204
def search(self, queryset, name, value):
11951205
if not value.strip():
11961206
return queryset
11971207
qs_filter = Q(name__icontains=value) | Q(description__icontains=value)
11981208
return queryset.filter(qs_filter)
11991209

1210+
def filter_device(self, queryset, name, value):
1211+
devices = Device.objects.filter(**{'{}__in'.format(name): value})
1212+
if not devices.exists():
1213+
return queryset.none()
1214+
service_ids = []
1215+
for device in devices:
1216+
service_ids.extend(device.services.values_list('id', flat=True))
1217+
return queryset.filter(id__in=service_ids)
1218+
1219+
def filter_fhrp_group(self, queryset, name, value):
1220+
groups = FHRPGroup.objects.filter(**{'{}__in'.format(name): value})
1221+
if not groups.exists():
1222+
return queryset.none()
1223+
service_ids = []
1224+
for group in groups:
1225+
service_ids.extend(group.services.values_list('id', flat=True))
1226+
return queryset.filter(id__in=service_ids)
1227+
1228+
def filter_virtual_machine(self, queryset, name, value):
1229+
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
1230+
if not virtual_machines.exists():
1231+
return queryset.none()
1232+
service_ids = []
1233+
for vm in virtual_machines:
1234+
service_ids.extend(vm.services.values_list('id', flat=True))
1235+
return queryset.filter(id__in=service_ids)
1236+
12001237

12011238
class PrimaryIPFilterSet(django_filters.FilterSet):
12021239
"""

netbox/ipam/forms/bulk_import.py

+53-14
Original file line numberDiff line numberDiff line change
@@ -559,19 +559,21 @@ class Meta:
559559

560560

561561
class ServiceImportForm(NetBoxModelImportForm):
562-
device = CSVModelChoiceField(
563-
label=_('Device'),
562+
parent_object_type = CSVContentTypeField(
563+
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
564+
required=True,
565+
label=_('Parent type (app & model)')
566+
)
567+
parent = CSVModelChoiceField(
568+
label=_('Parent'),
564569
queryset=Device.objects.all(),
565570
required=False,
566571
to_field_name='name',
567-
help_text=_('Required if not assigned to a VM')
572+
help_text=_('Parent object name')
568573
)
569-
virtual_machine = CSVModelChoiceField(
570-
label=_('Virtual machine'),
571-
queryset=VirtualMachine.objects.all(),
574+
parent_object_id = forms.IntegerField(
572575
required=False,
573-
to_field_name='name',
574-
help_text=_('Required if not assigned to a device')
576+
help_text=_('Parent object ID'),
575577
)
576578
protocol = CSVChoiceField(
577579
label=_('Protocol'),
@@ -588,15 +590,52 @@ class ServiceImportForm(NetBoxModelImportForm):
588590
class Meta:
589591
model = Service
590592
fields = (
591-
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
593+
'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
592594
)
593595

594-
def clean_ipaddresses(self):
595-
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
596-
for ip_address in self.cleaned_data['ipaddresses']:
596+
def __init__(self, data=None, *args, **kwargs):
597+
super().__init__(data, *args, **kwargs)
598+
599+
# Limit parent queryset by assigned parent object type
600+
if data:
601+
match data.get('parent_object_type'):
602+
case 'dcim.device':
603+
self.fields['parent'].queryset = Device.objects.all()
604+
case 'ipam.fhrpgroup':
605+
self.fields['parent'].queryset = FHRPGroup.objects.all()
606+
case 'virtualization.virtualmachine':
607+
self.fields['parent'].queryset = VirtualMachine.objects.all()
608+
609+
def save(self, *args, **kwargs):
610+
if (parent := self.cleaned_data.get('parent')):
611+
self.instance.parent = parent
612+
613+
return super().save(*args, **kwargs)
614+
615+
def clean(self):
616+
super().clean()
617+
618+
if (parent_ct := self.cleaned_data.get('parent_object_type')):
619+
if (parent := self.cleaned_data.get('parent')):
620+
self.cleaned_data['parent_object_id'] = parent.pk
621+
elif (parent_id := self.cleaned_data.get('parent_object_id')):
622+
parent = parent_ct.model_class().objects.filter(id=parent_id).first()
623+
self.cleaned_data['parent'] = parent
624+
else:
625+
# If a parent object type is passed and we've made it here, then raise a validation
626+
# error since an associated parent object or parent object id has not been passed
627+
raise forms.ValidationError(
628+
_("One of parent or parent_object_id must be included with parent_object_type")
629+
)
630+
631+
# making sure parent is defined. In cases where an import is resulting in an update, the
632+
# import data might not include the parent object and so the above logic might not be
633+
# triggered
634+
parent = self.cleaned_data.get('parent')
635+
for ip_address in self.cleaned_data.get('ipaddresses', []):
597636
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
598637
raise forms.ValidationError(
599-
_("{ip} is not assigned to this device/VM.").format(ip=ip_address)
638+
_("{ip} is not assigned to this parent.").format(ip=ip_address)
600639
)
601640

602-
return self.cleaned_data['ipaddresses']
641+
return self.cleaned_data

netbox/ipam/forms/filtersets.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
612612
fieldsets = (
613613
FieldSet('q', 'filter_id', 'tag'),
614614
FieldSet('protocol', 'port', name=_('Attributes')),
615-
FieldSet('device_id', 'virtual_machine_id', name=_('Assignment')),
615+
FieldSet('device_id', 'virtual_machine_id', 'fhrpgroup_id', name=_('Assignment')),
616616
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
617617
)
618618
device_id = DynamicModelMultipleChoiceField(
@@ -625,4 +625,9 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
625625
required=False,
626626
label=_('Virtual Machine'),
627627
)
628+
fhrpgroup_id = DynamicModelMultipleChoiceField(
629+
queryset=FHRPGroup.objects.all(),
630+
required=False,
631+
label=_('FHRP Group'),
632+
)
628633
tag = TagFilterField(model)

0 commit comments

Comments
 (0)