From bb4f3e178960f2387174b51c7beaaec19184a65b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 1 Nov 2021 16:14:44 -0400 Subject: [PATCH 1/8] Initial work on #6235 --- netbox/dcim/models/device_components.py | 6 ++ netbox/dcim/views.py | 4 +- netbox/ipam/api/nested_serializers.py | 13 +++ netbox/ipam/api/serializers.py | 37 ++++++++ netbox/ipam/api/urls.py | 4 + netbox/ipam/api/views.py | 16 ++++ netbox/ipam/choices.py | 32 +++++++ netbox/ipam/constants.py | 1 + netbox/ipam/filtersets.py | 37 +++++++- netbox/ipam/forms/bulk_edit.py | 36 +++++++ netbox/ipam/forms/bulk_import.py | 15 +++ netbox/ipam/forms/filtersets.py | 36 +++++++ netbox/ipam/forms/models.py | 79 +++++++++++++++- netbox/ipam/graphql/schema.py | 6 ++ netbox/ipam/graphql/types.py | 21 +++++ netbox/ipam/migrations/0052_fhrpgroup.py | 57 ++++++++++++ netbox/ipam/models/__init__.py | 3 + netbox/ipam/models/fhrp.py | 92 ++++++++++++++++++ netbox/ipam/tables/__init__.py | 1 + netbox/ipam/tables/fhrp.py | 64 +++++++++++++ netbox/ipam/tables/ip.py | 6 +- netbox/ipam/tests/test_api.py | 41 ++++++++ netbox/ipam/tests/test_filtersets.py | 27 ++++++ netbox/ipam/tests/test_views.py | 35 +++++++ netbox/ipam/urls.py | 17 ++++ netbox/ipam/views.py | 98 +++++++++++++++++++- netbox/netbox/navigation_menu.py | 3 +- netbox/templates/dcim/interface.html | 36 +++++++ netbox/templates/inc/panels/nhrp_groups.html | 49 ++++++++++ netbox/templates/ipam/fhrpgroup.html | 82 ++++++++++++++++ netbox/templates/ipam/ipaddress.html | 12 ++- netbox/virtualization/models.py | 6 ++ netbox/virtualization/views.py | 4 +- 33 files changed, 959 insertions(+), 17 deletions(-) create mode 100644 netbox/ipam/migrations/0052_fhrpgroup.py create mode 100644 netbox/ipam/models/fhrp.py create mode 100644 netbox/ipam/tables/fhrp.py create mode 100644 netbox/templates/inc/panels/nhrp_groups.html create mode 100644 netbox/templates/ipam/fhrpgroup.html diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e166c44ab..0d01435a3 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -599,6 +599,12 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): object_id_field='assigned_object_id', related_query_name='interface' ) + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='content_type', + object_id_field='object_id', + related_query_name='interface' + ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5079e01a5..daf3e13b4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -15,7 +15,7 @@ from django.views.generic import View from circuits.models import Circuit from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import IPAddress, Prefix, Service, VLAN -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -1741,7 +1741,7 @@ class InterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index a52a6a03c..e94dad24f 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', + 'NestedFHRPGroupSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', 'NestedPrefixSerializer', @@ -65,6 +66,18 @@ class NestedAggregateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'family', 'prefix'] +# +# FHRP groups +# + +class NestedFHRPGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + + class Meta: + model = models.FHRPGroup + fields = ['id', 'url', 'display', 'protocol', 'group_id'] + + # # VLANs # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 2b221fdab..e2a3c1954 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -92,6 +92,43 @@ class AggregateSerializer(PrimaryModelSerializer): read_only_fields = ['family'] +# +# FHRP Groups +# + +class FHRPGroupSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + + class Meta: + model = FHRPGroup + fields = [ + 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + +class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = FHRPGroupAssignment + fields = [ + 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'priority', 'created', 'last_updated', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, obj): + if obj.object is None: + return None + serializer = get_serializer_for_model(obj.object, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.object, context=context).data + + # # VLANs # diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 06c4ab0ea..60f4b6b72 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -27,6 +27,10 @@ router.register('ip-ranges', views.IPRangeViewSet) # IP addresses router.register('ip-addresses', views.IPAddressViewSet) +# FHRP groups +router.register('fhrp-groups', views.FHRPGroupViewSet) +router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet) + # VLANs router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index a043bd88c..6199c0caf 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -119,6 +119,22 @@ class IPAddressViewSet(CustomFieldModelViewSet): filterset_class = filtersets.IPAddressFilterSet +# +# FHRP groups +# + +class FHRPGroupViewSet(CustomFieldModelViewSet): + queryset = FHRPGroup.objects.prefetch_related('tags') + serializer_class = serializers.FHRPGroupSerializer + filterset_class = filtersets.FHRPGroupFilterSet + + +class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): + queryset = FHRPGroupAssignment.objects.prefetch_related('group') + serializer_class = serializers.FHRPGroupAssignmentSerializer + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + # # VLAN groups # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index e3a45f577..638ef62f6 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -124,6 +124,38 @@ class IPAddressRoleChoices(ChoiceSet): } +# +# FHRP +# + +class FHRPGroupProtocolChoices(ChoiceSet): + + PROTOCOL_VRRP2 = 'vrrp2' + PROTOCOL_VRRP3 = 'vrrp3' + PROTOCOL_HSRP = 'hsrp' + PROTOCOL_GLBP = 'glbp' + PROTOCOL_CARP = 'carp' + + CHOICES = ( + (PROTOCOL_VRRP2, 'VRRPv2'), + (PROTOCOL_VRRP3, 'VRRPv3'), + (PROTOCOL_HSRP, 'HSRP'), + (PROTOCOL_GLBP, 'GLBP'), + (PROTOCOL_CARP, 'CARP'), + ) + + +class FHRPGroupAuthTypeChoices(ChoiceSet): + + AUTHENTICATION_PLAINTEXT = 'plaintext' + AUTHENTICATION_MD5 = 'md5' + + CHOICES = ( + (AUTHENTICATION_PLAINTEXT, 'Plaintext'), + (AUTHENTICATION_MD5, 'MD5'), + ) + + # # VLANs # diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 9dd9328b8..1c370a65d 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -34,6 +34,7 @@ PREFIX_LENGTH_MAX = 127 # IPv6 IPADDRESS_ASSIGNMENT_MODELS = Q( Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='fhrpgroup') | Q(app_label='virtualization', model='vminterface') ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 56d23387f..89bb61c02 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -7,7 +7,7 @@ from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import TagFilter -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -19,6 +19,8 @@ from .models import * __all__ = ( 'AggregateFilterSet', + 'FHRPGroupAssignmentFilterSet', + 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', 'PrefixFilterSet', @@ -611,6 +613,39 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) +class FHRPGroupFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + protocol = django_filters.MultipleChoiceFilter( + choices=FHRPGroupProtocolChoices + ) + auth_type = django_filters.MultipleChoiceFilter( + choices=FHRPGroupAuthTypeChoices + ) + tag = TagFilter() + + class Meta: + model = FHRPGroup + fields = ['id', 'protocol', 'group_id', 'auth_type'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) + ) + + +class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): + content_type = ContentTypeFilter() + + class Meta: + model = FHRPGroupAssignment + fields = ['id', 'content_type_id', 'priority'] + + class VLANGroupFilterSet(OrganizationalModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 43bf40f88..7f910faa4 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -13,6 +13,7 @@ from utilities.forms import ( __all__ = ( 'AggregateBulkEditForm', + 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', 'PrefixBulkEditForm', @@ -280,6 +281,41 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] +class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=FHRPGroup.objects.all(), + widget=forms.MultipleHiddenInput() + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupProtocolChoices), + required=False, + widget=StaticSelect() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.ChoiceField( + choices=add_blank_choice(FHRPGroupAuthTypeChoices), + required=False, + widget=StaticSelect(), + label='Authentication type' + ) + auth_key = forms.CharField( + max_length=255, + required=False, + label='Authentication key' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['auth_type', 'auth_key', 'description'] + + class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 49d5014f9..fd9a9e715 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -12,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface __all__ = ( 'AggregateCSVForm', + 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', 'PrefixCSVForm', @@ -283,6 +284,20 @@ class IPAddressCSVForm(CustomFieldModelCSVForm): return ipaddress +class FHRPGroupCSVForm(CustomFieldModelCSVForm): + protocol = CSVChoiceField( + choices=FHRPGroupProtocolChoices + ) + auth_type = CSVChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False + ) + + class Meta: + model = FHRPGroup + fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'description') + + class VLANGroupCSVForm(CustomFieldModelCSVForm): slug = SlugField() scope_type = CSVContentTypeField( diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 415664f62..67927a016 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -14,6 +14,7 @@ from utilities.forms import ( __all__ = ( 'AggregateFilterForm', + 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', 'PrefixFilterForm', @@ -356,6 +357,41 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil tag = TagFilterField(model) +class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = FHRPGroup + field_groups = ( + ('q', 'tag'), + ('protocol', 'group_id'), + ('auth_type', 'auth_key'), + ) + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + protocol = forms.MultipleChoiceField( + choices=FHRPGroupProtocolChoices, + required=False, + widget=StaticSelectMultiple() + ) + group_id = forms.IntegerField( + min_value=0, + required=False, + label='Group ID' + ) + auth_type = forms.MultipleChoiceField( + choices=FHRPGroupAuthTypeChoices, + required=False, + widget=StaticSelectMultiple(), + label='Authentication type' + ) + auth_key = forms.CharField( + required=False, + label='Authentication key' + ) + tag = TagFilterField(model) + + class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): field_groups = [ ['q', 'tag'], diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index a9c8a0910..36a078071 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -4,17 +4,22 @@ from django.contrib.contenttypes.models import ContentType from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.forms import CustomFieldModelForm from extras.models import Tag +from ipam.choices import * from ipam.constants import * +from ipam.formfields import IPNetworkFormField from ipam.models import * from tenancy.forms import TenancyForm +from utilities.exceptions import PermissionsViolation from utilities.forms import ( - BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, + add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, NumericArrayField, SlugField, StaticSelect, StaticSelectMultiple, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface __all__ = ( 'AggregateForm', + 'FHRPGroupForm', + 'FHRPGroupAssignmentForm', 'IPAddressAssignForm', 'IPAddressBulkAddForm', 'IPAddressForm', @@ -472,6 +477,76 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) +class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + # Optionally create a new IPAddress along with the NHRPGroup + ip_vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + ip_address = IPNetworkFormField( + required=False, + label='Address' + ) + ip_status = forms.ChoiceField( + choices=add_blank_choice(IPAddressStatusChoices), + required=False, + label='Status' + ) + + class Meta: + model = FHRPGroup + fields = ( + 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags', + ) + fieldsets = ( + ('FHRP Group', ('protocol', 'group_id', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_key')), + ('Virtual IP Address', ('ip_vrf', 'ip_address', 'ip_status')) + ) + + def save(self, *args, **kwargs): + instance = super().save(*args, **kwargs) + + # Check if we need to create a new IPAddress for the group + if self.cleaned_data.get('ip_address'): + ipaddress = IPAddress( + vrf=self.cleaned_data['ip_vrf'], + address=self.cleaned_data['ip_address'], + status=self.cleaned_data['ip_status'], + assigned_object=instance + ) + ipaddress.role = { + FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, + FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, + FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, + }[self.cleaned_data['protocol']] + ipaddress.save() + + # Check that the new IPAddress conforms with any assigned object-level permissions + if not IPAddress.objects.filter(pk=ipaddress.pk).first(): + raise PermissionsViolation() + + return instance + + +class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): + group = DynamicModelChoiceField( + queryset=FHRPGroup.objects.all() + ) + + class Meta: + model = FHRPGroupAssignment + fields = ('group', 'priority') + + class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 58909e57f..8c7830a24 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -29,6 +29,12 @@ class IPAMQuery(graphene.ObjectType): service = ObjectField(ServiceType) service_list = ObjectListField(ServiceType) + fhrp_group = ObjectField(FHRPGroupType) + fhrp_group_list = ObjectListField(FHRPGroupType) + + fhrp_group_assignment = ObjectField(FHRPGroupAssignmentType) + fhrp_group_assignment_list = ObjectListField(FHRPGroupAssignmentType) + vlan = ObjectField(VLANType) vlan_list = ObjectListField(VLANType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index c822dab6b..8e4119266 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -3,6 +3,8 @@ from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType __all__ = ( 'AggregateType', + 'FHRPGroupType', + 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', 'PrefixType', @@ -24,6 +26,25 @@ class AggregateType(PrimaryObjectType): filterset_class = filtersets.AggregateFilterSet +class FHRPGroupType(PrimaryObjectType): + + class Meta: + model = models.FHRPGroup + fields = '__all__' + filterset_class = filtersets.FHRPGroupFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + +class FHRPGroupAssignmentType(PrimaryObjectType): + + class Meta: + model = models.FHRPGroupAssignment + fields = '__all__' + filterset_class = filtersets.FHRPGroupAssignmentFilterSet + + class IPAddressType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py new file mode 100644 index 000000000..f61191a7e --- /dev/null +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -0,0 +1,57 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0064_configrevision'), + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0051_extend_tag_support'), + ] + + operations = [ + migrations.CreateModel( + name='FHRPGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('group_id', models.PositiveSmallIntegerField()), + ('protocol', models.CharField(max_length=50)), + ('auth_type', models.CharField(blank=True, max_length=50)), + ('auth_key', models.CharField(blank=True, max_length=255)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'FHRP group', + 'ordering': ['protocol', 'group_id', 'pk'], + }, + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'fhrpgroup')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + migrations.CreateModel( + name='FHRPGroupAssignment', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('object_id', models.PositiveIntegerField()), + ('priority', models.PositiveSmallIntegerField(blank=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), + ], + options={ + 'verbose_name': 'FHRP group assignment', + 'ordering': ('priority', 'pk'), + 'unique_together': {('content_type', 'object_id', 'group')}, + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index cb8b4b932..9747bcfb0 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -1,3 +1,4 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * @@ -7,6 +8,8 @@ __all__ = ( 'Aggregate', 'IPAddress', 'IPRange', + 'FHRPGroup', + 'FHRPGroupAssignment', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py new file mode 100644 index 000000000..c108032b4 --- /dev/null +++ b/netbox/ipam/models/fhrp.py @@ -0,0 +1,92 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.urls import reverse + +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel, PrimaryModel +from ipam.choices import * +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'FHRPGroup', + 'FHRPGroupAssignment', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class FHRPGroup(PrimaryModel): + """ + A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) + """ + group_id = models.PositiveSmallIntegerField( + verbose_name='Group ID' + ) + protocol = models.CharField( + max_length=50, + choices=FHRPGroupProtocolChoices + ) + auth_type = models.CharField( + max_length=50, + choices=FHRPGroupAuthTypeChoices, + blank=True, + verbose_name='Authentication type' + ) + auth_key = models.CharField( + max_length=255, + blank=True, + verbose_name='Authentication key' + ) + description = models.CharField( + max_length=200, + blank=True + ) + ip_addresses = GenericRelation( + to='ipam.IPAddress', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='nhrp_group' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'protocol', 'auth_type', 'auth_key' + ] + + class Meta: + ordering = ['protocol', 'group_id', 'pk'] + verbose_name = 'FHRP group' + + def __str__(self): + return f'{self.get_protocol_display()} group {self.group_id}' + + def get_absolute_url(self): + return reverse('ipam:fhrpgroup', args=[self.pk]) + + +@extras_features('webhooks') +class FHRPGroupAssignment(ChangeLoggedModel): + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE + ) + object_id = models.PositiveIntegerField() + object = GenericForeignKey( + ct_field='content_type', + fk_field='object_id' + ) + group = models.ForeignKey( + to='ipam.FHRPGroup', + on_delete=models.CASCADE + ) + priority = models.PositiveSmallIntegerField( + blank=True, + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('priority', 'pk') + unique_together = ('content_type', 'object_id', 'group') + verbose_name = 'FHRP group assignment' diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index a280eac1b..6f429e27d 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,3 +1,4 @@ +from .fhrp import * from .ip import * from .services import * from .vlans import * diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py new file mode 100644 index 000000000..e3411cd7e --- /dev/null +++ b/netbox/ipam/tables/fhrp.py @@ -0,0 +1,64 @@ +import django_tables2 as tables + +from utilities.tables import ( + BaseTable, ContentTypeColumn, MarkdownColumn, TagColumn, ToggleColumn, +) +from ipam.models import * + +__all__ = ( + 'FHRPGroupTable', + 'FHRPGroupAssignmentTable', +) + + +IPADDRESSES = """ +{% for ip in record.ip_addresses.all %} + {{ ip }}
+{% endfor %} +""" + + +class FHRPGroupTable(BaseTable): + pk = ToggleColumn() + group_id = tables.Column( + linkify=True + ) + comments = MarkdownColumn() + ip_addresses = tables.TemplateColumn( + template_code=IPADDRESSES, + orderable=False, + verbose_name='IP Addresses' + ) + member_count = tables.Column( + verbose_name='Members' + ) + tags = TagColumn( + url_name='ipam:fhrpgroup_list' + ) + + class Meta(BaseTable.Meta): + model = FHRPGroup + fields = ( + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'tags', + ) + default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') + + +class FHRPGroupAssignmentTable(BaseTable): + pk = ToggleColumn() + content_type = ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) + group = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = FHRPGroupAssignment + fields = ('pk', 'content_type', 'object', 'group', 'priority') + default_columns = ('pk', 'content_type', 'object', 'group', 'priority') diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index a2a0c67b1..a35bb7b78 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -11,7 +11,7 @@ from ipam.models import * __all__ = ( 'AggregateTable', - 'InterfaceIPAddressTable', + 'AssignedIPAddressesTable', 'IPAddressAssignTable', 'IPAddressTable', 'IPRangeTable', @@ -359,9 +359,9 @@ class IPAddressAssignTable(BaseTable): orderable = False -class InterfaceIPAddressTable(BaseTable): +class AssignedIPAddressesTable(BaseTable): """ - List IP addresses assigned to a specific Interface. + List IP addresses assigned to an object. """ address = tables.Column( linkify=True, diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 5ba45b7fd..f3796f781 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -491,6 +491,47 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): IPAddress.objects.bulk_create(ip_addresses) +class FHRPGroupTest(APIViewTestCases.APIViewTestCase): + model = FHRPGroup + brief_fields = ['display', 'group_id', 'id', 'protocol', 'url'] + bulk_update_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 200, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'foobarbaz999', + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + cls.create_data = [ + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 110, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, + 'auth_key': 'foobar123', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP3, + 'group_id': 120, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'barfoo456', + }, + { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP, + 'group_id': 130, + }, + ] + + class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index ff9dbfece..e5df58a2b 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -795,6 +795,33 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = FHRPGroup.objects.all() + filterset = FHRPGroupFilterSet + + @classmethod + def setUpTestData(cls): + + fhrp_groups = ( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + ) + FHRPGroup.objects.bulk_create(fhrp_groups) + + def test_protocol(self): + params = {'protocol': [FHRPGroupProtocolChoices.PROTOCOL_VRRP2, FHRPGroupProtocolChoices.PROTOCOL_VRRP3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group_id(self): + params = {'group_id': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = VLANGroup.objects.all() filterset = VLANGroupFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 5440efcb6..da4627ca8 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -372,6 +372,41 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class FHRPGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = FHRPGroup + + @classmethod + def setUpTestData(cls): + + FHRPGroup.objects.bulk_create(( + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP2, group_id=10, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_PLAINTEXT, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_VRRP3, group_id=20, auth_type=FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, auth_key='foobar123'), + FHRPGroup(protocol=FHRPGroupProtocolChoices.PROTOCOL_HSRP, group_id=30), + )) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_VRRP2, + 'group_id': 99, + 'auth_type': FHRPGroupAuthTypeChoices.AUTHENTICATION_MD5, + 'auth_key': 'abc123def456', + 'description': 'Blah blah blah', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "protocol,group_id,auth_type,auth_key,description", + "vrrp2,40,plaintext,foobar123,Foo", + "vrrp3,50,md5,foobar123,Bar", + "hsrp,60,,,", + ) + + cls.bulk_edit_data = { + 'protocol': FHRPGroupProtocolChoices.PROTOCOL_CARP, + } + + class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = VLANGroup diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9d9a846bf..ccce246cd 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -107,6 +107,23 @@ urlpatterns = [ path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), path('ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + # FHRP groups + path('fhrp-groups/', views.FHRPGroupListView.as_view(), name='fhrpgroup_list'), + path('fhrp-groups/add/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_add'), + path('fhrp-groups/import/', views.FHRPGroupBulkImportView.as_view(), name='fhrpgroup_import'), + path('fhrp-groups/edit/', views.FHRPGroupBulkEditView.as_view(), name='fhrpgroup_bulk_edit'), + path('fhrp-groups/delete/', views.FHRPGroupBulkDeleteView.as_view(), name='fhrpgroup_bulk_delete'), + path('fhrp-groups//', views.FHRPGroupView.as_view(), name='fhrpgroup'), + path('fhrp-groups//edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'), + path('fhrp-groups//delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'), + path('fhrp-groups//changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}), + path('fhrp-groups//journal/', ObjectJournalView.as_view(), name='fhrpgroup_journal', kwargs={'model': FHRPGroup}), + + # FHRP group assignments + path('fhrp-group-assignments/add/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_add'), + path('fhrp-group-assignments//edit/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_edit'), + path('fhrp-group-assignments//delete/', views.FHRPGroupAssignmentDeleteView.as_view(), name='fhrpgroupassignment_delete'), + # VLAN groups path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index c24a80124..b4864577d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,10 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django.db.models import Prefetch from django.db.models.expressions import RawSQL +from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render from dcim.models import Device, Interface from netbox.views import generic -from utilities.forms import TableConfigForm from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface @@ -825,6 +826,101 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView): table = tables.VLANGroupTable +# +# FHRP groups +# + +class FHRPGroupListView(generic.ObjectListView): + queryset = FHRPGroup.objects.annotate( + member_count=count_related(FHRPGroupAssignment, 'group') + ) + filterset = filtersets.FHRPGroupFilterSet + filterset_form = forms.FHRPGroupFilterForm + table = tables.FHRPGroupTable + + +class FHRPGroupView(generic.ObjectView): + queryset = FHRPGroup.objects.all() + + def get_extra_context(self, request, instance): + # Get assigned IP addresses + ipaddress_table = tables.AssignedIPAddressesTable( + data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + orderable=False + ) + + group_assignments = FHRPGroupAssignment.objects.restrict(request.user, 'view').filter( + group=instance + ) + members_table = tables.FHRPGroupAssignmentTable(group_assignments) + members_table.columns.hide('group') + paginate_table(members_table, request) + + return { + 'ipaddress_table': ipaddress_table, + 'members_table': members_table, + 'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(), + } + + +class FHRPGroupEditView(generic.ObjectEditView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupForm + + +class FHRPGroupDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroup.objects.all() + + +class FHRPGroupBulkImportView(generic.BulkImportView): + queryset = FHRPGroup.objects.all() + model_form = forms.FHRPGroupCSVForm + table = tables.FHRPGroupTable + + +class FHRPGroupBulkEditView(generic.BulkEditView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + form = forms.FHRPGroupBulkEditForm + + +class FHRPGroupBulkDeleteView(generic.BulkDeleteView): + queryset = FHRPGroup.objects.all() + filterset = filtersets.FHRPGroupFilterSet + table = tables.FHRPGroupTable + + +# +# FHRP group assignments +# + +class FHRPGroupAssignmentEditView(generic.ObjectEditView): + queryset = FHRPGroupAssignment.objects.all() + model_form = forms.FHRPGroupAssignmentForm + + def alter_obj(self, instance, request, args, kwargs): + if not instance.pk: + # Assign the object based on URL kwargs + try: + app_label, model = request.GET.get('content_type').split('.') + except (AttributeError, ValueError): + raise Http404("Content type not specified") + content_type = get_object_or_404(ContentType, app_label=app_label, model=model) + instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + return instance + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + +class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): + queryset = FHRPGroupAssignment.objects.all() + + def get_return_url(self, request, obj=None): + return obj.object.get_absolute_url() if obj else super().get_return_url(request) + + # # VLANs # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 993c5e171..1d06f1d5c 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -251,8 +251,9 @@ IPAM_MENU = Menu( ), ), MenuGroup( - label='Services', + label='Other', items=( + get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), get_model_item('ipam', 'service', 'Services', actions=['import']), ), ), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 5851b3aeb..a6dc1a901 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -440,6 +440,42 @@ {% endif %} +
+
NHRP Groups
+
+ + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + {% empty %} + + + + {% endfor %} + +
GroupPriority
+ {{ assignment.group }} + + {{ assignment.priority }} +
None
+
+ {% if perms.ipam.add_fhrpgroupassignment %} + + {% endif %} +
{% plugin_right_page object %} diff --git a/netbox/templates/inc/panels/nhrp_groups.html b/netbox/templates/inc/panels/nhrp_groups.html new file mode 100644 index 000000000..223354441 --- /dev/null +++ b/netbox/templates/inc/panels/nhrp_groups.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
+
Contacts
+
+ {% with fhrp_groups=object.fhrp_group_assignments.all %} + {% if contacts.exists %} + + + + + + + + {% for contact in contacts %} + + + + + + + {% endfor %} +
ProtocolGroup IDPriority
+ {{ contact.contact }} + {{ contact.role|placeholder }}{{ contact.get_priority_display|placeholder }} + {% if perms.tenancy.change_contactassignment %} + + + + {% endif %} + {% if perms.tenancy.delete_contactassignment %} + + + + {% endif %} +
+ {% else %} +
None
+ {% endif %} + {% endwith %} +
+ {% if perms.tenancy.add_contactassignment %} + + {% endif %} +
diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html new file mode 100644 index 000000000..c4e3eadc3 --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup.html @@ -0,0 +1,82 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock breadcrumbs %} + +{% block content %} +
+
+
+
FHRP Group
+
+ + + + + + + + + + + + + + + + + +
Protocol{{ object.get_protocol_display }}
Group ID{{ object.group_id }}
Description{{ object.description|placeholder }}
Members{{ member_count }}
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+
+
Authentication
+
+ + + + + + + + + +
Authentication Type{{ object.get_auth_type_display|placeholder }}
Authentication Key{{ object.auth_key|placeholder }}
+
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+
+
IP Addresses
+
+ {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %} +
+
+
+
Members
+
+ {% include 'inc/table.html' with table=members_table %} +
+
+ {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 31782bdd7..c39f4398a 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -73,12 +73,14 @@ Assignment - {% if object.assigned_object %} - {{ object.assigned_object.parent_object }} / - {{ object.assigned_object }} - {% else %} - + {% if object.assigned_object %} + {% if object.assigned_object.parent_object %} + {{ object.assigned_object.parent_object }} / {% endif %} + {{ object.assigned_object }} + {% else %} + + {% endif %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index db2404546..3567b86c5 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -398,6 +398,12 @@ class VMInterface(PrimaryModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) + fhrp_group_assignments = GenericRelation( + to='ipam.FHRPGroupAssignment', + content_type_field='content_type', + object_id_field='object_id', + related_query_name='vminterface' + ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 2294d2c38..5cb4f133a 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -8,7 +8,7 @@ from dcim.models import Device from dcim.tables import DeviceTable from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service -from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable +from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic from utilities.tables import paginate_table from utilities.utils import count_related @@ -421,7 +421,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses - ipaddress_table = InterfaceIPAddressTable( + ipaddress_table = AssignedIPAddressesTable( data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), orderable=False ) From f48d7aedcee7a85d8c197152a63e80839151ff29 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 09:05:56 -0400 Subject: [PATCH 2/8] Enable filtering FHRP groups by related IP addresses --- netbox/ipam/filtersets.py | 24 ++++++++++ netbox/ipam/forms/models.py | 7 +++ netbox/ipam/migrations/0052_fhrpgroup.py | 2 +- netbox/ipam/models/fhrp.py | 1 + netbox/ipam/tables/fhrp.py | 18 +++++--- netbox/ipam/views.py | 8 ++-- netbox/templates/dcim/interface.html | 37 +-------------- netbox/templates/ipam/fhrpgroup.html | 8 ++-- .../ipam/inc/panels/fhrp_groups.html | 45 +++++++++++++++++++ .../templates/virtualization/vminterface.html | 3 +- 10 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 netbox/templates/ipam/inc/panels/fhrp_groups.html diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 89bb61c02..5d385c7ef 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -624,6 +624,10 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): auth_type = django_filters.MultipleChoiceFilter( choices=FHRPGroupAuthTypeChoices ) + related_ip = django_filters.ModelMultipleChoiceFilter( + queryset=IPAddress.objects.all(), + method='filter_related_ip' + ) tag = TagFilter() class Meta: @@ -637,6 +641,26 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): Q(description__icontains=value) ) + def filter_related_ip(self, queryset, name, value): + """ + Filter by VRF & prefix of assigned IP addresses. + """ + ip_filter = Q() + for ipaddress in value: + if ipaddress.vrf: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf=ipaddress.vrf + ) + else: + q = Q( + ip_addresses__address__net_contained_or_equal=ipaddress.address, + ip_addresses__vrf__isnull=True + ) + ip_filter |= q + + return queryset.filter(ip_filter) + class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): content_type = ContentTypeFilter() diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 36a078071..c605a7b7c 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -546,6 +546,13 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): model = FHRPGroupAssignment fields = ('group', 'priority') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + ipaddresses = self.instance.object.ip_addresses.all() + for ipaddress in ipaddresses: + self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) + class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): scope_type = ContentTypeChoiceField( diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index f61191a7e..17d4ec9ca 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('object_id', models.PositiveIntegerField()), - ('priority', models.PositiveSmallIntegerField(blank=True)), + ('priority', models.PositiveSmallIntegerField(blank=True, null=True)), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), ], diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index c108032b4..01ab6b5f8 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -82,6 +82,7 @@ class FHRPGroupAssignment(ChangeLoggedModel): ) priority = models.PositiveSmallIntegerField( blank=True, + null=True ) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index e3411cd7e..8aae4bba7 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,8 +1,6 @@ import django_tables2 as tables -from utilities.tables import ( - BaseTable, ContentTypeColumn, MarkdownColumn, TagColumn, ToggleColumn, -) +from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * __all__ = ( @@ -47,8 +45,11 @@ class FHRPGroupTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable): pk = ToggleColumn() - content_type = ContentTypeColumn( - verbose_name='Object Type' + object_parent = tables.Column( + accessor=tables.A('object.parent_object'), + linkify=True, + orderable=False, + verbose_name='Parent' ) object = tables.Column( linkify=True, @@ -57,8 +58,11 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) + actions = ButtonsColumn( + model=FHRPGroupAssignment, + buttons=('edit', 'delete', 'foo') + ) class Meta(BaseTable.Meta): model = FHRPGroupAssignment - fields = ('pk', 'content_type', 'object', 'group', 'priority') - default_columns = ('pk', 'content_type', 'object', 'group', 'priority') + fields = ('pk', 'group', 'object_parent', 'object', 'priority') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index b4864577d..8746787fe 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -849,12 +849,12 @@ class FHRPGroupView(generic.ObjectView): orderable=False ) - group_assignments = FHRPGroupAssignment.objects.restrict(request.user, 'view').filter( - group=instance + # Get assigned interfaces + members_table = tables.FHRPGroupAssignmentTable( + data=FHRPGroupAssignment.objects.restrict(request.user, 'view').filter(group=instance), + orderable=False ) - members_table = tables.FHRPGroupAssignmentTable(group_assignments) members_table.columns.hide('group') - paginate_table(members_table, request) return { 'ipaddress_table': ipaddress_table, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index a6dc1a901..f4ab30e4d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -440,42 +440,7 @@ {% endif %} - + {% include 'ipam/inc/panels/fhrp_groups.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index c4e3eadc3..7f0b5d56e 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -72,10 +72,12 @@
Members
- {% include 'inc/table.html' with table=members_table %} + {% if ipaddress_table.rows %} + {% render_table members_table 'inc/table.html' %} + {% else %} +
None
+ {% endif %}
-
- {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %} {% plugin_full_width_page object %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html new file mode 100644 index 000000000..b3168b61c --- /dev/null +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -0,0 +1,45 @@ +{% load helpers %} + +
+
NHRP Groups
+
+ + + + + + + + + + {% for assignment in object.fhrp_group_assignments.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
GroupVirtual IPsPriority
+ {{ assignment.group }} + + {% for ipaddress in assignment.group.ip_addresses.all %} + {{ ipaddress }} + {% if not forloop.last %}
{% endif %} + {% endfor %} +
+ {{ assignment.priority }} +
None
+
+ {% if perms.ipam.add_fhrpgroupassignment %} + + {% endif %} +
diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 2646686e8..2a201bf85 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -76,11 +76,12 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} {% plugin_right_page object %}
From aeb4996ac2f50c8c9f298a7e598b3f9ae744b0f1 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:06:58 -0400 Subject: [PATCH 3/8] Allow users to create new FHRP group directly from the interface view --- netbox/ipam/views.py | 11 +++++++++++ netbox/templates/ipam/inc/panels/fhrp_groups.html | 13 +++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 8746787fe..14849c91f 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,6 +3,7 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.http import Http404 from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from dcim.models import Device, Interface from netbox.views import generic @@ -867,6 +868,16 @@ class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() model_form = forms.FHRPGroupForm + def get_return_url(self, request, obj=None): + return_url = super().get_return_url(request, obj) + + # If we're redirecting the user to the FHRPGroupAssignment creation form, + # initialize the group field with the FHRPGroup we just saved. + if return_url.startswith(reverse('ipam:fhrpgroupassignment_add')): + return_url += f'&group={obj.pk}' + + return return_url + class FHRPGroupDeleteView(generic.ObjectDeleteView): queryset = FHRPGroup.objects.all() diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index b3168b61c..3ed4f1761 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -35,11 +35,16 @@ - {% if perms.ipam.add_fhrpgroupassignment %} - From 93da5a39be9613e9cd49ce313d58e2a68629f307 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:15:23 -0400 Subject: [PATCH 4/8] Make assignment priority a required field --- netbox/ipam/constants.py | 8 ++++++++ netbox/ipam/migrations/0052_fhrpgroup.py | 3 ++- netbox/ipam/models/fhrp.py | 8 ++++++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 1c370a65d..fdb1dc6d9 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -52,6 +52,14 @@ IPADDRESS_ROLES_NONUNIQUE = ( ) +# +# FHRP groups +# + +FHRPGROUPASSIGNMENT_PRIORITY_MIN = 0 +FHRPGROUPASSIGNMENT_PRIORITY_MAX = 255 + + # # VLANs # diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 17d4ec9ca..9a3f41aab 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -1,4 +1,5 @@ import django.core.serializers.json +import django.core.validators from django.db import migrations, models import django.db.models.deletion import taggit.managers @@ -44,7 +45,7 @@ class Migration(migrations.Migration): ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('object_id', models.PositiveIntegerField()), - ('priority', models.PositiveSmallIntegerField(blank=True, null=True)), + ('priority', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(255)])), ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), ], diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 01ab6b5f8..3544c0a00 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -1,11 +1,13 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse from extras.utils import extras_features from netbox.models import ChangeLoggedModel, PrimaryModel from ipam.choices import * +from ipam.constants import * from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -81,8 +83,10 @@ class FHRPGroupAssignment(ChangeLoggedModel): on_delete=models.CASCADE ) priority = models.PositiveSmallIntegerField( - blank=True, - null=True + validators=( + MinValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MIN), + MaxValueValidator(FHRPGROUPASSIGNMENT_PRIORITY_MAX) + ) ) objects = RestrictedQuerySet.as_manager() From 2cb53a0f7efd62e14a06b5627096460c0f24c657 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 13:32:41 -0400 Subject: [PATCH 5/8] Clean up FHRP group templates, forms --- netbox/ipam/constants.py | 10 ++++- netbox/ipam/forms/models.py | 8 +--- netbox/ipam/views.py | 2 + netbox/templates/ipam/fhrpgroup.html | 2 +- netbox/templates/ipam/fhrpgroup_edit.html | 40 +++++++++++++++++++ .../ipam/fhrpgroupassignment_edit.html | 18 +++++++++ 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 netbox/templates/ipam/fhrpgroup_edit.html create mode 100644 netbox/templates/ipam/fhrpgroupassignment_edit.html diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index fdb1dc6d9..b19d4061b 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -1,6 +1,6 @@ from django.db.models import Q -from .choices import IPAddressRoleChoices +from .choices import FHRPGroupProtocolChoices, IPAddressRoleChoices # BGP ASN bounds BGP_ASN_MIN = 1 @@ -59,6 +59,14 @@ IPADDRESS_ROLES_NONUNIQUE = ( FHRPGROUPASSIGNMENT_PRIORITY_MIN = 0 FHRPGROUPASSIGNMENT_PRIORITY_MAX = 255 +FHRP_PROTOCOL_ROLE_MAPPINGS = { + FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, + FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, + FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, + FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, +} + # # VLANs diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index c605a7b7c..d421bdbcd 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -521,13 +521,7 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm): status=self.cleaned_data['ip_status'], assigned_object=instance ) - ipaddress.role = { - FHRPGroupProtocolChoices.PROTOCOL_VRRP2: IPAddressRoleChoices.ROLE_VRRP, - FHRPGroupProtocolChoices.PROTOCOL_VRRP3: IPAddressRoleChoices.ROLE_VRRP, - FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, - FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, - FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, - }[self.cleaned_data['protocol']] + ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']] ipaddress.save() # Check that the new IPAddress conforms with any assigned object-level permissions diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 14849c91f..d9bd1977a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -867,6 +867,7 @@ class FHRPGroupView(generic.ObjectView): class FHRPGroupEditView(generic.ObjectEditView): queryset = FHRPGroup.objects.all() model_form = forms.FHRPGroupForm + template_name = 'ipam/fhrpgroup_edit.html' def get_return_url(self, request, obj=None): return_url = super().get_return_url(request, obj) @@ -909,6 +910,7 @@ class FHRPGroupBulkDeleteView(generic.BulkDeleteView): class FHRPGroupAssignmentEditView(generic.ObjectEditView): queryset = FHRPGroupAssignment.objects.all() model_form = forms.FHRPGroupAssignmentForm + template_name = 'ipam/fhrpgroupassignment_edit.html' def alter_obj(self, instance, request, args, kwargs): if not instance.pk: diff --git a/netbox/templates/ipam/fhrpgroup.html b/netbox/templates/ipam/fhrpgroup.html index 7f0b5d56e..60d6a4bff 100644 --- a/netbox/templates/ipam/fhrpgroup.html +++ b/netbox/templates/ipam/fhrpgroup.html @@ -60,7 +60,7 @@
-
IP Addresses
+
Virtual IP Addresses
{% if ipaddress_table.rows %} {% render_table ipaddress_table 'inc/table.html' %} diff --git a/netbox/templates/ipam/fhrpgroup_edit.html b/netbox/templates/ipam/fhrpgroup_edit.html new file mode 100644 index 000000000..858d265ab --- /dev/null +++ b/netbox/templates/ipam/fhrpgroup_edit.html @@ -0,0 +1,40 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group
+
+ {% render_field form.protocol %} + {% render_field form.group_id %} + {% render_field form.description %} + {% render_field form.tags %} +
+ +
+
+
Authentication
+
+ {% render_field form.auth_type %} + {% render_field form.auth_key %} +
+ + {% if not form.instance.pk %} +
+
+
Virtual IP Address
+
+ {% render_field form.ip_vrf %} + {% render_field form.ip_address %} + {% render_field form.ip_status %} +
+ {% endif %} + + {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} + {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html new file mode 100644 index 000000000..730d2a15a --- /dev/null +++ b/netbox/templates/ipam/fhrpgroupassignment_edit.html @@ -0,0 +1,18 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
FHRP Group Assignment
+
+
+ +
+ +
+
+ {% render_field form.group %} + {% render_field form.priority %} +
+{% endblock %} From 264652f2c38c17f13740f6115e930699b956f79c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 14:08:36 -0400 Subject: [PATCH 6/8] REST API optimizations --- netbox/ipam/api/serializers.py | 5 +++-- netbox/ipam/api/views.py | 4 ++-- netbox/ipam/models/fhrp.py | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e2a3c1954..525ea393e 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -98,12 +98,13 @@ class AggregateSerializer(PrimaryModelSerializer): class FHRPGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroup-detail') + ip_addresses = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = FHRPGroup fields = [ - 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_addresses', + 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 6199c0caf..dffe555e9 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -124,13 +124,13 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class FHRPGroupViewSet(CustomFieldModelViewSet): - queryset = FHRPGroup.objects.prefetch_related('tags') + queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags') serializer_class = serializers.FHRPGroupSerializer filterset_class = filtersets.FHRPGroupFilterSet class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): - queryset = FHRPGroupAssignment.objects.prefetch_related('group') + queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'object') serializer_class = serializers.FHRPGroupAssignmentSerializer filterset_class = filtersets.FHRPGroupAssignmentFilterSet diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 3544c0a00..ee5a9a2be 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -95,3 +95,6 @@ class FHRPGroupAssignment(ChangeLoggedModel): ordering = ('priority', 'pk') unique_together = ('content_type', 'object_id', 'group') verbose_name = 'FHRP group assignment' + + def __str__(self): + return f'{self.object}: {self.group} ({self.priority})' From 131e433880fc369290cd21208b4befc029ccaa6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 15:10:02 -0400 Subject: [PATCH 7/8] Rename FHRPGroupAssignment object to interface --- netbox/dcim/models/device_components.py | 6 +++--- netbox/ipam/api/serializers.py | 15 ++++++++------- netbox/ipam/api/views.py | 2 +- netbox/ipam/filtersets.py | 8 ++++++-- netbox/ipam/forms/models.py | 2 +- netbox/ipam/migrations/0052_fhrpgroup.py | 8 ++++---- netbox/ipam/models/fhrp.py | 14 +++++++------- netbox/ipam/tables/fhrp.py | 12 ++++++------ netbox/ipam/views.py | 10 +++++----- netbox/templates/dcim/interface.html | 2 +- .../templates/ipam/fhrpgroupassignment_edit.html | 2 +- netbox/templates/ipam/inc/panels/fhrp_groups.html | 10 +++++++--- netbox/templates/virtualization/vminterface.html | 2 +- netbox/virtualization/models.py | 6 +++--- 14 files changed, 54 insertions(+), 45 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 0d01435a3..a957aba41 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -601,9 +601,9 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', - content_type_field='content_type', - object_id_field='object_id', - related_query_name='interface' + content_type_field='interface_type', + object_id_field='interface_id', + related_query_name='+' ) clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 525ea393e..25c2297ab 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -110,24 +110,25 @@ class FHRPGroupSerializer(PrimaryModelSerializer): class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') - content_type = ContentTypeField( + interface_type = ContentTypeField( queryset=ContentType.objects.all() ) - object = serializers.SerializerMethodField(read_only=True) + interface = serializers.SerializerMethodField(read_only=True) class Meta: model = FHRPGroupAssignment fields = [ - 'id', 'url', 'display', 'content_type', 'object_id', 'object', 'priority', 'created', 'last_updated', + 'id', 'url', 'display', 'interface_type', 'interface_id', 'interface', 'priority', 'created', + 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_object(self, obj): - if obj.object is None: + def get_interface(self, obj): + if obj.interface is None: return None - serializer = get_serializer_for_model(obj.object, prefix='Nested') + serializer = get_serializer_for_model(obj.interface, prefix='Nested') context = {'request': self.context['request']} - return serializer(obj.object, context=context).data + return serializer(obj.interface, context=context).data # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dffe555e9..a0ad4f375 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -130,7 +130,7 @@ class FHRPGroupViewSet(CustomFieldModelViewSet): class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet): - queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'object') + queryset = FHRPGroupAssignment.objects.prefetch_related('group', 'interface') serializer_class = serializers.FHRPGroupAssignmentSerializer filterset_class = filtersets.FHRPGroupAssignmentFilterSet diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 5d385c7ef..db2f5aaea 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -663,11 +663,15 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): - content_type = ContentTypeFilter() + interface_type = ContentTypeFilter() + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=FHRPGroup.objects.all(), + label='Group (ID)', + ) class Meta: model = FHRPGroupAssignment - fields = ['id', 'content_type_id', 'priority'] + fields = ['id', 'group_id', 'interface_type', 'interface_id', 'priority'] class VLANGroupFilterSet(OrganizationalModelFilterSet): diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d421bdbcd..70094a07a 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -543,7 +543,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - ipaddresses = self.instance.object.ip_addresses.all() + ipaddresses = self.instance.interface.ip_addresses.all() for ipaddress in ipaddresses: self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk) diff --git a/netbox/ipam/migrations/0052_fhrpgroup.py b/netbox/ipam/migrations/0052_fhrpgroup.py index 9a3f41aab..976084b47 100644 --- a/netbox/ipam/migrations/0052_fhrpgroup.py +++ b/netbox/ipam/migrations/0052_fhrpgroup.py @@ -8,8 +8,8 @@ import taggit.managers class Migration(migrations.Migration): dependencies = [ - ('extras', '0064_configrevision'), ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0064_configrevision'), ('ipam', '0051_extend_tag_support'), ] @@ -44,15 +44,15 @@ class Migration(migrations.Migration): ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('object_id', models.PositiveIntegerField()), + ('interface_id', models.PositiveIntegerField()), ('priority', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(255)])), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ipam.fhrpgroup')), + ('interface_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), ], options={ 'verbose_name': 'FHRP group assignment', 'ordering': ('priority', 'pk'), - 'unique_together': {('content_type', 'object_id', 'group')}, + 'unique_together': {('interface_type', 'interface_id', 'group')}, }, ), ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index ee5a9a2be..95c907cfd 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -69,14 +69,14 @@ class FHRPGroup(PrimaryModel): @extras_features('webhooks') class FHRPGroupAssignment(ChangeLoggedModel): - content_type = models.ForeignKey( + interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() - object = GenericForeignKey( - ct_field='content_type', - fk_field='object_id' + interface_id = models.PositiveIntegerField() + interface = GenericForeignKey( + ct_field='interface_type', + fk_field='interface_id' ) group = models.ForeignKey( to='ipam.FHRPGroup', @@ -93,8 +93,8 @@ class FHRPGroupAssignment(ChangeLoggedModel): class Meta: ordering = ('priority', 'pk') - unique_together = ('content_type', 'object_id', 'group') + unique_together = ('interface_type', 'interface_id', 'group') verbose_name = 'FHRP group assignment' def __str__(self): - return f'{self.object}: {self.group} ({self.priority})' + return f'{self.interface}: {self.group} ({self.priority})' diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 8aae4bba7..8a31694bf 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable): orderable=False, verbose_name='IP Addresses' ) - member_count = tables.Column( - verbose_name='Members' + interface_count = tables.Column( + verbose_name='Interfaces' ) tags = TagColumn( url_name='ipam:fhrpgroup_list' @@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable): class Meta(BaseTable.Meta): model = FHRPGroup fields = ( - 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'member_count', + 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', 'tags', ) - default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'member_count') + default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') class FHRPGroupAssignmentTable(BaseTable): @@ -51,7 +51,7 @@ class FHRPGroupAssignmentTable(BaseTable): orderable=False, verbose_name='Parent' ) - object = tables.Column( + interface = tables.Column( linkify=True, orderable=False ) @@ -65,4 +65,4 @@ class FHRPGroupAssignmentTable(BaseTable): class Meta(BaseTable.Meta): model = FHRPGroupAssignment - fields = ('pk', 'group', 'object_parent', 'object', 'priority') + fields = ('pk', 'group', 'object_parent', 'interface', 'priority') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index d9bd1977a..8592fc931 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -914,24 +914,24 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView): def alter_obj(self, instance, request, args, kwargs): if not instance.pk: - # Assign the object based on URL kwargs + # Assign the interface based on URL kwargs try: - app_label, model = request.GET.get('content_type').split('.') + app_label, model = request.GET.get('interface_type').split('.') except (AttributeError, ValueError): raise Http404("Content type not specified") content_type = get_object_or_404(ContentType, app_label=app_label, model=model) - instance.object = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id')) + instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id')) return instance def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) + return obj.interface.get_absolute_url() if obj else super().get_return_url(request) class FHRPGroupAssignmentDeleteView(generic.ObjectDeleteView): queryset = FHRPGroupAssignment.objects.all() def get_return_url(self, request, obj=None): - return obj.object.get_absolute_url() if obj else super().get_return_url(request) + return obj.interface.get_absolute_url() if obj else super().get_return_url(request) # diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index f4ab30e4d..811bf6257 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -459,7 +459,7 @@
{% if perms.ipam.add_ipaddress %} diff --git a/netbox/templates/ipam/fhrpgroupassignment_edit.html b/netbox/templates/ipam/fhrpgroupassignment_edit.html index 730d2a15a..5801febca 100644 --- a/netbox/templates/ipam/fhrpgroupassignment_edit.html +++ b/netbox/templates/ipam/fhrpgroupassignment_edit.html @@ -9,7 +9,7 @@
- +
{% render_field form.group %} diff --git a/netbox/templates/ipam/inc/panels/fhrp_groups.html b/netbox/templates/ipam/inc/panels/fhrp_groups.html index 3ed4f1761..e5cb26104 100644 --- a/netbox/templates/ipam/inc/panels/fhrp_groups.html +++ b/netbox/templates/ipam/inc/panels/fhrp_groups.html @@ -7,6 +7,7 @@ Group + Protocol Virtual IPs Priority @@ -15,7 +16,10 @@ {% for assignment in object.fhrp_group_assignments.all %} - {{ assignment.group }} + {{ assignment.group.group_id }} + + + {{ assignment.group.get_protocol_display }} {% for ipaddress in assignment.group.ip_addresses.all %} @@ -37,12 +41,12 @@
{% if perms.ipam.add_ipaddress %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 3567b86c5..08df36d4d 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -400,9 +400,9 @@ class VMInterface(PrimaryModel, BaseInterface): ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', - content_type_field='content_type', - object_id_field='object_id', - related_query_name='vminterface' + content_type_field='interface_type', + object_id_field='interface_id', + related_query_name='+' ) objects = RestrictedQuerySet.as_manager() From 412430e1c3eca4adb09050984bf7bf63793e5635 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 2 Nov 2021 15:26:45 -0400 Subject: [PATCH 8/8] Docs & changelog for #6235 --- docs/core-functionality/ipam.md | 4 ++++ docs/models/ipam/fhrpgroup.md | 14 ++++++++++++++ docs/release-notes/version-3.1.md | 4 ++++ 3 files changed, 22 insertions(+) create mode 100644 docs/models/ipam/fhrpgroup.md diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index c1e77069e..dd05d6a01 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -17,3 +17,7 @@ {!models/ipam/vrf.md!} {!models/ipam/routetarget.md!} + +__ + +{!models/ipam/fhrpgroup.md!} diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md new file mode 100644 index 000000000..ec21ca37f --- /dev/null +++ b/docs/models/ipam/fhrpgroup.md @@ -0,0 +1,14 @@ +# FHRP Group + +A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to present a virtual IP address in a redundant manner. Example of such protocols include: + +* Hot Standby Router Protocol (HSRP) +* Virtual Router Redundancy Protocol (VRRP) +* Common Address Redundancy Protocol (CARP) +* Gateway Load Balancing Protocol (GLBP) + +NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses. + +## FHRP Group Assignments + +Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority. diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ff615a92b..65d3627e5 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -37,6 +37,10 @@ Dynamic configuration parameters may also still be defined within `configuration For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). +#### First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235)) + +A new FHRP group model has been introduced to aid in modeling the configurations of protocols such as HSRP, VRRP, and GLBP. Each FHRP group may be assigned one or more virtual IP addresses, as well as an authentication type and key. Member device and VM interfaces may be associated with one or more FHRP groups, with each assignment receiving a numeric priority designation. + #### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: