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

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='device',
)
# Counter fields
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.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__ = (
@ -25,8 +29,6 @@ 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)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
ipaddresses = SerializedPKRelatedField(
queryset=IPAddress.objects.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

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)'),
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',
@ -1189,7 +1199,7 @@ 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():
@ -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

@ -559,19 +559,21 @@ class ServiceTemplateImportForm(NetBoxModelImportForm):
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'),
@ -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

View File

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

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.widgets import DatePicker, HTMXSelect
from utilities.templatetags.builtins.filters import bettertitle
from virtualization.models import VirtualMachine, VMInterface
from virtualization.models import VMInterface
__all__ = (
'AggregateForm',
@ -759,16 +759,17 @@ class ServiceTemplateForm(NetBoxModelForm):
class ServiceForm(NetBoxModelForm):
device = DynamicModelChoiceField(
label=_('Device'),
queryset=Device.objects.all(),
required=False,
selector=True
parent_object_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(SERVICE_ASSIGNMENT_MODELS),
widget=HTMXSelect(),
required=True,
label=_('Parent type')
)
virtual_machine = DynamicModelChoiceField(
label=_('Virtual machine'),
queryset=VirtualMachine.objects.all(),
required=False,
parent = DynamicModelChoiceField(
label=_('Parent'),
queryset=Device.objects.none(), # Initial queryset
required=True,
disabled=True,
selector=True
)
ports = NumericArrayField(
@ -792,11 +793,7 @@ class ServiceForm(NetBoxModelForm):
fieldsets = (
FieldSet(
TabbedGroups(
FieldSet('device', name=_('Device')),
FieldSet('virtual_machine', name=_('Virtual Machine')),
),
'name',
'parent_object_type', 'parent', 'name',
InlineFields('protocol', 'ports', label=_('Port(s)')),
'ipaddresses', 'description', 'tags', name=_('Service')
),
@ -805,9 +802,38 @@ 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',
'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):
service_template = DynamicModelChoiceField(
@ -818,10 +844,7 @@ class ServiceCreateForm(ServiceForm):
fieldsets = (
FieldSet(
TabbedGroups(
FieldSet('device', name=_('Device')),
FieldSet('virtual_machine', name=_('Virtual Machine')),
),
'parent_object_type', 'parent',
TabbedGroups(
FieldSet('service_template', name=_('From Template')),
FieldSet('name', 'protocol', 'ports', name=_('Custom')),
@ -832,8 +855,8 @@ class ServiceCreateForm(ServiceForm):
class Meta(ServiceForm.Meta):
fields = [
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
'comments', 'tags',
'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
'comments', 'tags', 'parent_object_type',
]
def __init__(self, *args, **kwargs):

View File

@ -19,8 +19,7 @@ from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
if TYPE_CHECKING:
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
from core.graphql.filters import ContentTypeFilter
from dcim.graphql.filters import DeviceFilter, SiteFilter
from virtualization.graphql.filters import VirtualMachineFilter
from dcim.graphql.filters import SiteFilter
from vpn.graphql.filters import L2VPNFilter
from .enums import *
@ -216,16 +215,14 @@ class RouteTargetFilter(TenancyFilterMixin, PrimaryModelFilterMixin):
@strawberry_django.filter(models.Service, lookups=True)
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()
ipaddresses: Annotated['IPAddressFilter', strawberry.lazy('ipam.graphql.filters')] | None = (
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)

View File

@ -241,17 +241,22 @@ class RouteTargetType(NetBoxObjectType):
@strawberry_django.type(
models.Service,
fields='__all__',
exclude=('parent_object_type', 'parent_object_id'),
filters=ServiceFilter,
pagination=True
)
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
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(
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',
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,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
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',
on_delete=models.PROTECT,
related_name='+',
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='services',
null=True,
blank=True
parent_object_id = models.PositiveBigIntegerField()
parent = GenericForeignKey(
ct_field='parent_object_type',
fk_field='parent_object_id'
)
name = models.CharField(
max_length=100,
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")
)
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

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

View File

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

View File

@ -1258,9 +1258,24 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
IPAddress.objects.bulk_create(ipaddresses)
services = (
Service(name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1]),
Service(
parent=devices[0],
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)
services[0].ipaddresses.add(ipaddresses[0])
@ -2329,41 +2344,57 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
)
VirtualMachine.objects.bulk_create(virtual_machines)
fhrp_group = FHRPGroup.objects.create(
name='telnet',
protocol=FHRPGroupProtocolChoices.PROTOCOL_CARP,
group_id=101,
)
services = (
Service(
device=devices[0],
parent=devices[0],
name='Service 1',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1001],
description='foobar1',
),
Service(
device=devices[1],
parent=devices[1],
name='Service 2',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[1002],
description='foobar2',
),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
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',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2001],
),
Service(
virtual_machine=virtual_machines[1],
parent=virtual_machines[1],
name='Service 5',
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
ports=[2002],
),
Service(
virtual_machine=virtual_machines[2],
parent=virtual_machines[2],
name='Service 6',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[2003],
),
Service(
parent=fhrp_group,
name='Service 7',
protocol=ServiceProtocolChoices.PROTOCOL_UDP,
ports=[2004],
),
)
Service.objects.bulk_create(services)
services[0].ipaddresses.add(ip_addresses[0])
@ -2404,6 +2435,13 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'virtual_machine': [vms[0].name, vms[1].name]}
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):
ips = IPAddress.objects.all()[:2]
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 netaddr import IPNetwork
from core.models import ObjectType
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
from ipam.choices import *
from ipam.models import *
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from tenancy.models import Tenant
from users.models import ObjectPermission
from utilities.testing import ViewTestCases, create_tags
@ -1053,6 +1056,8 @@ class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service
# TODO, related to #9816, cannot validate GFK
validation_excluded_fields = ('device',)
@classmethod
def setUpTestData(cls):
@ -1065,9 +1070,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)
@ -1080,8 +1085,8 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'virtual_machine': None,
'parent_object_type': ContentType.objects.get_for_model(Device).pk,
'parent': device.pk,
'name': 'Service X',
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
'ports': '104,105',
@ -1091,10 +1096,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
cls.csv_data = (
"device,name,protocol,ports,ipaddresses,description",
"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",
"Device 1,Service 3,udp,3,,Third service",
"parent_object_type,parent,name,protocol,ports,ipaddresses,description",
"dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
"dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
"dcim.device,Device 1,Service 3,udp,3,,Third service",
)
cls.csv_update_data = (
@ -1110,6 +1115,66 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'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=['*'])
def test_create_from_template(self):
self.add_permissions('ipam.add_service')
@ -1125,13 +1190,15 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
request = {
'path': self._get_url('add'),
'data': {
'device': device.pk,
'parent_object_type': ContentType.objects.get_for_model(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

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site
from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable
from netbox.views import generic
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 virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface
from virtualization.models import VirtualMachine, VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
from .constants import *
@ -1161,7 +1161,7 @@ class FHRPGroupListView(generic.ObjectListView):
@register_model_view(FHRPGroup)
class FHRPGroupView(generic.ObjectView):
class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = FHRPGroup.objects.all()
def get_extra_context(self, request, instance):
@ -1173,6 +1173,18 @@ class FHRPGroupView(generic.ObjectView):
members_table.columns.hide('group')
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,
'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
}
@ -1409,7 +1421,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
@ -1419,6 +1431,18 @@ class ServiceListView(generic.ObjectListView):
class ServiceView(generic.ObjectView):
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)
class ServiceCreateView(generic.ObjectEditView):
@ -1445,7 +1469,7 @@ class ServiceBulkImportView(generic.BulkImportView):
@register_model_view(Service, 'bulk_edit', path='edit', detail=False)
class ServiceBulkEditView(generic.BulkEditView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable
form = forms.ServiceBulkEditForm
@ -1453,6 +1477,6 @@ class ServiceBulkEditView(generic.BulkEditView):
@register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
queryset = Service.objects.prefetch_related('parent')
filterset = filtersets.ServiceFilterSet
table = tables.ServiceTable

View File

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

View File

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

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='virtual_machine',
)
# Counter fields
interface_count = CounterCacheField(