Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Apr 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/models/ipam/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ To aid in the efficient creation of services, users may opt to first create a [s

## 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

A service or protocol name.
Expand Down
8 changes: 7 additions & 1 deletion netbox/dcim/models/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

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
Expand Down Expand Up @@ -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='device',
)

# Counter fields
console_port_count = CounterCacheField(
Expand Down
29 changes: 22 additions & 7 deletions netbox/ipam/api/serializers_/services.py
Original file line number Diff line number Diff line change
@@ -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.constants import SERVICE_ASSIGNMENT_MODELS
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 virtualization.api.serializers_.virtualmachines import VirtualMachineSerializer
from utilities.api import get_serializer_for_model
from .ip import IPAddressSerializer

__all__ = (
Expand All @@ -25,8 +29,6 @@ class Meta:


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)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.all(),
Expand All @@ -35,11 +37,24 @@ class ServiceSerializer(NetBoxModelSerializer):
required=False,
many=True
)
parent_object_type = ContentTypeField(
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS)
)
parent = serializers.SerializerMethodField(read_only=True)

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', 'parent_object_type', 'parent_object_id', 'parent', '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
6 changes: 6 additions & 0 deletions netbox/ipam/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@
# 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
69 changes: 53 additions & 16 deletions netbox/ipam/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1150,26 +1150,36 @@ def search(self, queryset, name, value):
return queryset.filter(qs_filter)


class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
class ServiceFilterSet(NetBoxModelFilterSet):
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label=_('Device (name)'),
)
device_id = MultiValueNumberFilter(
method='filter_device',
field_name='pk',
label=_('Device (ID)'),
)
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label=_('Device (name)'),
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label=_('Virtual machine (name)'),
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(),
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)'),
fhrpgroup = MultiValueCharFilter(
method='filter_fhrp_group',
field_name='name',
label=_('FHRP Group (name)'),
)
fhrpgroup_id = MultiValueNumberFilter(
method='filter_fhrp_group',
field_name='pk',
label=_('FHRP Group (ID)'),
)
ip_address_id = django_filters.ModelMultipleChoiceFilter(
field_name='ipaddresses',
Expand All @@ -1189,14 +1199,41 @@ class ServiceFilterSet(ContactModelFilterSet, NetBoxModelFilterSet):

class Meta:
model = Service
fields = ('id', 'name', 'protocol', 'description')
fields = ('id', 'name', 'protocol', 'description', 'parent_object_type', 'parent_object_id')

def search(self, queryset, name, value):
if not value.strip():
return queryset
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):
"""
Expand Down
67 changes: 53 additions & 14 deletions netbox/ipam/forms/bulk_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,19 +559,21 @@ class Meta:


class ServiceImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField(
label=_('Device'),
parent_object_type = CSVContentTypeField(
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
required=True,
label=_('Parent type (app & model)')
)
parent = CSVModelChoiceField(
label=_('Parent'),
queryset=Device.objects.all(),
required=False,
to_field_name='name',
help_text=_('Required if not assigned to a VM')
help_text=_('Parent object name')
)
virtual_machine = CSVModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
parent_object_id = forms.IntegerField(
required=False,
to_field_name='name',
help_text=_('Required if not assigned to a device')
help_text=_('Parent object ID'),
)
protocol = CSVChoiceField(
label=_('Protocol'),
Expand All @@ -588,15 +590,52 @@ class ServiceImportForm(NetBoxModelImportForm):
class Meta:
model = Service
fields = (
'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
)

def clean_ipaddresses(self):
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
for ip_address in self.cleaned_data['ipaddresses']:
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)

# 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:
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
7 changes: 6 additions & 1 deletion netbox/ipam/forms/filtersets.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
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')),
)
device_id = DynamicModelMultipleChoiceField(
Expand All @@ -625,4 +625,9 @@ class ServiceFilterForm(ContactModelFilterForm, ServiceTemplateFilterForm):
required=False,
label=_('Virtual Machine'),
)
fhrpgroup_id = DynamicModelMultipleChoiceField(
queryset=FHRPGroup.objects.all(),
required=False,
label=_('FHRP Group'),
)
tag = TagFilterField(model)
Loading