jeremystretch 4 lat temu
rodzic
commit
bb4f3e1789

+ 6 - 0
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']
 

+ 2 - 2
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
         )

+ 13 - 0
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
 #

+ 37 - 0
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
 #

+ 4 - 0
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)

+ 16 - 0
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
 #

+ 32 - 0
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
 #

+ 1 - 0
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')
 )
 

+ 36 - 1
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',

+ 36 - 0
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(),

+ 15 - 0
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(

+ 36 - 0
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'],

+ 77 - 2
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),

+ 6 - 0
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)
 

+ 21 - 0
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:

+ 57 - 0
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')},
+            },
+        ),
+    ]

+ 3 - 0
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',

+ 92 - 0
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'

+ 1 - 0
netbox/ipam/tables/__init__.py

@@ -1,3 +1,4 @@
+from .fhrp import *
 from .ip import *
 from .services import *
 from .vlans import *

+ 64 - 0
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 %}
+  <a href="{{ ip.get_absolute_url }}">{{ ip }}</a><br />
+{% 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')

+ 3 - 3
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,

+ 41 - 0
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']

+ 27 - 0
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

+ 35 - 0
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
 

+ 17 - 0
netbox/ipam/urls.py

@@ -107,6 +107,23 @@ urlpatterns = [
     path('ip-addresses/<int:pk>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     path('ip-addresses/<int:pk>/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/<int:pk>/', views.FHRPGroupView.as_view(), name='fhrpgroup'),
+    path('fhrp-groups/<int:pk>/edit/', views.FHRPGroupEditView.as_view(), name='fhrpgroup_edit'),
+    path('fhrp-groups/<int:pk>/delete/', views.FHRPGroupDeleteView.as_view(), name='fhrpgroup_delete'),
+    path('fhrp-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='fhrpgroup_changelog', kwargs={'model': FHRPGroup}),
+    path('fhrp-groups/<int:pk>/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/<int:pk>/edit/', views.FHRPGroupAssignmentEditView.as_view(), name='fhrpgroupassignment_edit'),
+    path('fhrp-group-assignments/<int:pk>/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'),

+ 97 - 1
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
 #

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

+ 36 - 0
netbox/templates/dcim/interface.html

@@ -440,6 +440,42 @@
                     </div>
                 </div>
             {% endif %}
+            <div class="card">
+                <h5 class="card-header">NHRP Groups</h5>
+                <div class="card-body">
+                    <table class="table table-hover table-headings">
+                        <thead>
+                            <tr>
+                                <th>Group</th>
+                                <th>Priority</th>
+                            </tr>
+                        </thead>
+                        <tbody>
+                            {% for assignment in object.fhrp_group_assignments.all %}
+                                <tr>
+                                    <td>
+                                        <a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group }}</a>
+                                    </td>
+                                    <td>
+                                        {{ assignment.priority }}
+                                    </td>
+                                </tr>
+                            {% empty %}
+                                <tr>
+                                    <td colspan="3" class="text-muted">None</td>
+                                </tr>
+                            {% endfor %}
+                        </tbody>
+                    </table>
+                </div>
+                {% if perms.ipam.add_fhrpgroupassignment %}
+                    <div class="card-footer text-end noprint">
+                        <a href="{% url 'ipam:fhrpgroupassignment_add' %}?content_type=dcim.interface&object_id={{ object.pk }}" class="btn btn-sm btn-primary">
+                            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> New Assignment
+                        </a>
+                    </div>
+                {% endif %}
+            </div>
             {% plugin_right_page object %}
         </div>
     </div>

+ 49 - 0
netbox/templates/inc/panels/nhrp_groups.html

@@ -0,0 +1,49 @@
+{% load helpers %}
+
+<div class="card">
+  <h5 class="card-header">Contacts</h5>
+  <div class="card-body">
+    {% with fhrp_groups=object.fhrp_group_assignments.all %}
+      {% if contacts.exists %}
+        <table class="table table-hover">
+          <tr>
+            <th>Protocol</th>
+            <th>Group ID</th>
+            <th>Priority</th>
+            <th></th>
+          </tr>
+          {% for contact in contacts %}
+            <tr>
+              <td>
+                <a href="{{ contact.contact.get_absolute_url }}">{{ contact.contact }}</a>
+              </td>
+              <td>{{ contact.role|placeholder }}</td>
+              <td>{{ contact.get_priority_display|placeholder }}</td>
+              <td class="text-end noprint">
+                {% if perms.tenancy.change_contactassignment %}
+                  <a href="{% url 'tenancy:contactassignment_edit' pk=contact.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit">
+                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+                {% if perms.tenancy.delete_contactassignment %}
+                  <a href="{% url 'extras:imageattachment_delete' pk=contact.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete">
+                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
+                  </a>
+                {% endif %}
+              </td>
+            </tr>
+          {% endfor %}
+        </table>
+      {% else %}
+        <div class="text-muted">None</div>
+      {% endif %}
+    {% endwith %}
+  </div>
+  {% if perms.tenancy.add_contactassignment %}
+    <div class="card-footer text-end noprint">
+      <a href="{% url 'tenancy:contactassignment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add a contact
+      </a>
+    </div>
+  {% endif %}
+</div>

+ 82 - 0
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 }}
+  <li class="breadcrumb-item"><a href="{% url 'ipam:fhrpgroup_list' %}?protocol={{ object.protocol }}">{{ object.get_protocol_display }}</a></li>
+{% endblock breadcrumbs %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">FHRP Group</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <td>Protocol</td>
+              <td>{{ object.get_protocol_display }}</td>
+            </tr>
+            <tr>
+              <td>Group ID</td>
+              <td>{{ object.group_id }}</td>
+            </tr>
+            <tr>
+              <td>Description</td>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Members</th>
+              <td>{{ member_count }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Authentication</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <td>Authentication Type</td>
+              <td>{{ object.get_auth_type_display|placeholder }}</td>
+            </tr>
+            <tr>
+              <td>Authentication Key</td>
+              <td>{{ object.auth_key|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">IP Addresses</h5>
+        <div class="card-body">
+          {% if ipaddress_table.rows %}
+            {% render_table ipaddress_table 'inc/table.html' %}
+          {% else %}
+            <div class="text-muted">None</div>
+          {% endif %}
+        </div>
+      </div>
+      <div class="card">
+        <h5 class="card-header">Members</h5>
+        <div class="card-body">
+          {% include 'inc/table.html' with table=members_table %}
+        </div>
+      </div>
+      {% include 'inc/paginator.html' with paginator=members_table.paginator page=members_table.page %}
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 7 - 5
netbox/templates/ipam/ipaddress.html

@@ -73,12 +73,14 @@
                     <tr>
                         <th scope="row">Assignment</th>
                         <td>
-                            {% if object.assigned_object %}
-                                <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
-                                <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
-                            {% else %}
-                                <span class="text-muted">&mdash;</span>
+                          {% if object.assigned_object %}
+                            {% if object.assigned_object.parent_object %}
+                              <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
                             {% endif %}
+                            <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
+                          {% else %}
+                            <span class="text-muted">&mdash;</span>
+                          {% endif %}
                         </td>
                     </tr>
                     <tr>

+ 6 - 0
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()
 

+ 2 - 2
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
         )