jeremystretch il y a 4 ans
Parent
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',
         object_id_field='assigned_object_id',
         related_query_name='interface'
         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']
     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 circuits.models import Circuit
 from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
 from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
 from ipam.models import IPAddress, Prefix, Service, VLAN
 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 netbox.views import generic
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -1741,7 +1741,7 @@ class InterfaceView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned IP addresses
         # Get assigned IP addresses
-        ipaddress_table = InterfaceIPAddressTable(
+        ipaddress_table = AssignedIPAddressesTable(
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             orderable=False
             orderable=False
         )
         )

+ 13 - 0
netbox/ipam/api/nested_serializers.py

@@ -5,6 +5,7 @@ from netbox.api import WritableNestedSerializer
 
 
 __all__ = [
 __all__ = [
     'NestedAggregateSerializer',
     'NestedAggregateSerializer',
+    'NestedFHRPGroupSerializer',
     'NestedIPAddressSerializer',
     'NestedIPAddressSerializer',
     'NestedIPRangeSerializer',
     'NestedIPRangeSerializer',
     'NestedPrefixSerializer',
     'NestedPrefixSerializer',
@@ -65,6 +66,18 @@ class NestedAggregateSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'family', 'prefix']
         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
 # VLANs
 #
 #

+ 37 - 0
netbox/ipam/api/serializers.py

@@ -92,6 +92,43 @@ class AggregateSerializer(PrimaryModelSerializer):
         read_only_fields = ['family']
         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
 # VLANs
 #
 #

+ 4 - 0
netbox/ipam/api/urls.py

@@ -27,6 +27,10 @@ router.register('ip-ranges', views.IPRangeViewSet)
 # IP addresses
 # IP addresses
 router.register('ip-addresses', views.IPAddressViewSet)
 router.register('ip-addresses', views.IPAddressViewSet)
 
 
+# FHRP groups
+router.register('fhrp-groups', views.FHRPGroupViewSet)
+router.register('fhrp-group-assignments', views.FHRPGroupAssignmentViewSet)
+
 # VLANs
 # VLANs
 router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlans', views.VLANViewSet)
 router.register('vlans', views.VLANViewSet)

+ 16 - 0
netbox/ipam/api/views.py

@@ -119,6 +119,22 @@ class IPAddressViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.IPAddressFilterSet
     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
 # 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
 # VLANs
 #
 #

+ 1 - 0
netbox/ipam/constants.py

@@ -34,6 +34,7 @@ PREFIX_LENGTH_MAX = 127  # IPv6
 
 
 IPADDRESS_ASSIGNMENT_MODELS = Q(
 IPADDRESS_ASSIGNMENT_MODELS = Q(
     Q(app_label='dcim', model='interface') |
     Q(app_label='dcim', model='interface') |
+    Q(app_label='ipam', model='fhrpgroup') |
     Q(app_label='virtualization', model='vminterface')
     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 dcim.models import Device, Interface, Region, Site, SiteGroup
 from extras.filters import TagFilter
 from extras.filters import TagFilter
-from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet
+from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from tenancy.filtersets import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
     ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
@@ -19,6 +19,8 @@ from .models import *
 
 
 __all__ = (
 __all__ = (
     'AggregateFilterSet',
     'AggregateFilterSet',
+    'FHRPGroupAssignmentFilterSet',
+    'FHRPGroupFilterSet',
     'IPAddressFilterSet',
     'IPAddressFilterSet',
     'IPRangeFilterSet',
     'IPRangeFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
@@ -611,6 +613,39 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         return queryset.exclude(assigned_object_id__isnull=value)
         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):
 class VLANGroupFilterSet(OrganizationalModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 36 - 0
netbox/ipam/forms/bulk_edit.py

@@ -13,6 +13,7 @@ from utilities.forms import (
 
 
 __all__ = (
 __all__ = (
     'AggregateBulkEditForm',
     'AggregateBulkEditForm',
+    'FHRPGroupBulkEditForm',
     'IPAddressBulkEditForm',
     'IPAddressBulkEditForm',
     'IPRangeBulkEditForm',
     'IPRangeBulkEditForm',
     'PrefixBulkEditForm',
     '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):
 class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
         queryset=VLANGroup.objects.all(),

+ 15 - 0
netbox/ipam/forms/bulk_import.py

@@ -12,6 +12,7 @@ from virtualization.models import VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (
     'AggregateCSVForm',
     'AggregateCSVForm',
+    'FHRPGroupCSVForm',
     'IPAddressCSVForm',
     'IPAddressCSVForm',
     'IPRangeCSVForm',
     'IPRangeCSVForm',
     'PrefixCSVForm',
     'PrefixCSVForm',
@@ -283,6 +284,20 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         return ipaddress
         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):
 class VLANGroupCSVForm(CustomFieldModelCSVForm):
     slug = SlugField()
     slug = SlugField()
     scope_type = CSVContentTypeField(
     scope_type = CSVContentTypeField(

+ 36 - 0
netbox/ipam/forms/filtersets.py

@@ -14,6 +14,7 @@ from utilities.forms import (
 
 
 __all__ = (
 __all__ = (
     'AggregateFilterForm',
     'AggregateFilterForm',
+    'FHRPGroupFilterForm',
     'IPAddressFilterForm',
     'IPAddressFilterForm',
     'IPRangeFilterForm',
     'IPRangeFilterForm',
     'PrefixFilterForm',
     'PrefixFilterForm',
@@ -356,6 +357,41 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
     tag = TagFilterField(model)
     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):
 class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['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 dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
 from extras.forms import CustomFieldModelForm
 from extras.forms import CustomFieldModelForm
 from extras.models import Tag
 from extras.models import Tag
+from ipam.choices import *
 from ipam.constants import *
 from ipam.constants import *
+from ipam.formfields import IPNetworkFormField
 from ipam.models import *
 from ipam.models import *
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
+from utilities.exceptions import PermissionsViolation
 from utilities.forms import (
 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
 from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (
     'AggregateForm',
     'AggregateForm',
+    'FHRPGroupForm',
+    'FHRPGroupAssignmentForm',
     'IPAddressAssignForm',
     'IPAddressAssignForm',
     'IPAddressBulkAddForm',
     'IPAddressBulkAddForm',
     'IPAddressForm',
     '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):
 class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
     scope_type = ContentTypeChoiceField(
     scope_type = ContentTypeChoiceField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         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 = ObjectField(ServiceType)
     service_list = ObjectListField(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 = ObjectField(VLANType)
     vlan_list = ObjectListField(VLANType)
     vlan_list = ObjectListField(VLANType)
 
 

+ 21 - 0
netbox/ipam/graphql/types.py

@@ -3,6 +3,8 @@ from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType
 
 
 __all__ = (
 __all__ = (
     'AggregateType',
     'AggregateType',
+    'FHRPGroupType',
+    'FHRPGroupAssignmentType',
     'IPAddressType',
     'IPAddressType',
     'IPRangeType',
     'IPRangeType',
     'PrefixType',
     'PrefixType',
@@ -24,6 +26,25 @@ class AggregateType(PrimaryObjectType):
         filterset_class = filtersets.AggregateFilterSet
         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 IPAddressType(PrimaryObjectType):
 
 
     class Meta:
     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 .ip import *
 from .services import *
 from .services import *
 from .vlans import *
 from .vlans import *
@@ -7,6 +8,8 @@ __all__ = (
     'Aggregate',
     'Aggregate',
     'IPAddress',
     'IPAddress',
     'IPRange',
     'IPRange',
+    'FHRPGroup',
+    'FHRPGroupAssignment',
     'Prefix',
     'Prefix',
     'RIR',
     'RIR',
     'Role',
     '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 .ip import *
 from .services import *
 from .services import *
 from .vlans 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__ = (
 __all__ = (
     'AggregateTable',
     'AggregateTable',
-    'InterfaceIPAddressTable',
+    'AssignedIPAddressesTable',
     'IPAddressAssignTable',
     'IPAddressAssignTable',
     'IPAddressTable',
     'IPAddressTable',
     'IPRangeTable',
     'IPRangeTable',
@@ -359,9 +359,9 @@ class IPAddressAssignTable(BaseTable):
         orderable = False
         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(
     address = tables.Column(
         linkify=True,
         linkify=True,

+ 41 - 0
netbox/ipam/tests/test_api.py

@@ -491,6 +491,47 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
         IPAddress.objects.bulk_create(ip_addresses)
         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):
 class VLANGroupTest(APIViewTestCases.APIViewTestCase):
     model = VLANGroup
     model = VLANGroup
     brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count']
     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)
         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):
 class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VLANGroup.objects.all()
     queryset = VLANGroup.objects.all()
     filterset = VLANGroupFilterSet
     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):
 class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = VLANGroup
     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>/edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
     path('ip-addresses/<int:pk>/delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),
     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
     # VLAN groups
     path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
     path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
     path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
     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 import Prefetch
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
+from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
 from netbox.views import generic
 from netbox.views import generic
-from utilities.forms import TableConfigForm
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, VMInterface
 from virtualization.models import VirtualMachine, VMInterface
@@ -825,6 +826,101 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.VLANGroupTable
     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
 # VLANs
 #
 #

+ 2 - 1
netbox/netbox/navigation_menu.py

@@ -251,8 +251,9 @@ IPAM_MENU = Menu(
             ),
             ),
         ),
         ),
         MenuGroup(
         MenuGroup(
-            label='Services',
+            label='Other',
             items=(
             items=(
+                get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
                 get_model_item('ipam', 'service', 'Services', actions=['import']),
                 get_model_item('ipam', 'service', 'Services', actions=['import']),
             ),
             ),
         ),
         ),

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

@@ -440,6 +440,42 @@
                     </div>
                     </div>
                 </div>
                 </div>
             {% endif %}
             {% 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 %}
             {% plugin_right_page object %}
         </div>
         </div>
     </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>
                     <tr>
                         <th scope="row">Assignment</th>
                         <th scope="row">Assignment</th>
                         <td>
                         <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 %}
                             {% endif %}
+                            <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
+                          {% else %}
+                            <span class="text-muted">&mdash;</span>
+                          {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>

+ 6 - 0
netbox/virtualization/models.py

@@ -398,6 +398,12 @@ class VMInterface(PrimaryModel, BaseInterface):
         object_id_field='assigned_object_id',
         object_id_field='assigned_object_id',
         related_query_name='vminterface'
         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()
     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 dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import IPAddress, Service
 from ipam.models import IPAddress, Service
-from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from utilities.utils import count_related
@@ -421,7 +421,7 @@ class VMInterfaceView(generic.ObjectView):
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned IP addresses
         # Get assigned IP addresses
-        ipaddress_table = InterfaceIPAddressTable(
+        ipaddress_table = AssignedIPAddressesTable(
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             orderable=False
             orderable=False
         )
         )