Allow assigning Service to FHRP Group, in addition to Device and VirtualMachine

This commit is contained in:
Jason Novinger 2025-03-25 10:14:34 -05:00
parent fc0acb020f
commit 60e8268882
15 changed files with 354 additions and 65 deletions

View File

@ -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(

View File

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

View File

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

View File

@ -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):
"""

View File

@ -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',
]

View File

@ -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,

View File

@ -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'
),
),
]

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', '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,
)
]

View File

@ -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',
),
]

View File

@ -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'
),
),
]

View File

@ -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')

View File

@ -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."))

View File

@ -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)

View File

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

View File

@ -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(