Quellcode durchsuchen

Merge pull request #14238 from netbox-community/develop

Release v3.6.5
Jeremy Stretch vor 2 Jahren
Ursprung
Commit
6ac25eeb65

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.6.4
+      placeholder: v3.6.5
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.6.4
+      placeholder: v3.6.5
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 37 - 0
.github/ISSUE_TEMPLATE/translation.yaml

@@ -0,0 +1,37 @@
+---
+name: 🌍 Translation
+description: Request support for a new language in the user interface
+labels: ["type: translation"]
+body:
+  - type: markdown
+    attributes:
+      value: >
+        **NOTE:** This template is used only for proposing the addition of *new* languages. Please do
+        not use it to request changes to existing translations.
+  - type: input
+    attributes:
+      label: Language
+      description: What is the name of the language in English?
+    validations:
+      required: true
+  - type: input
+    attributes:
+      label: ISO 639-1 code
+      description: >
+        What is the two-letter [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes)
+        assigned to the language?
+    validations:
+      required: true
+  - type: dropdown
+    attributes:
+      label: Volunteer
+      description: Are you a fluent speaker of this language **and** willing to contribute a translation map?
+      options:
+        - "Yes"
+        - "No"
+    validations:
+      required: true
+  - type: textarea
+    attributes:
+      label: Comments
+      description: Any other notes you would like to share

+ 2 - 1
base_requirements.txt

@@ -53,7 +53,8 @@ django-tables2
 
 
 # User-defined tags for objects
 # User-defined tags for objects
 # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
 # https://github.com/jazzband/django-taggit/blob/master/CHANGELOG.rst
-django-taggit
+# TODO: Upgrade to v5.0 for NetBox v3.7 beta
+django-taggit<5.0
 
 
 # A Django field for representing time zones
 # A Django field for representing time zones
 # https://github.com/mfogel/django-timezone-field/
 # https://github.com/mfogel/django-timezone-field/

+ 1 - 1
docs/reference/conditions.md

@@ -116,7 +116,7 @@ Multiple conditions can be combined into nested sets using AND or OR logic. This
       ]
       ]
     },
     },
     {
     {
-      "attr": "tags",
+      "attr": "tags.slug",
       "value": "exempt",
       "value": "exempt",
       "op": "contains"
       "op": "contains"
     }
     }

+ 30 - 0
docs/release-notes/version-3.6.md

@@ -1,5 +1,35 @@
 # NetBox v3.6
 # NetBox v3.6
 
 
+## v3.6.5 (2023-11-09)
+
+### Enhancements
+
+* [#12741](https://github.com/netbox-community/netbox/issues/12741) - Add selector widget to platform field on device & virtual machine forms
+* [#13022](https://github.com/netbox-community/netbox/issues/13022) - Introduce support for assigning IP addresses when bulk importing services
+* [#13587](https://github.com/netbox-community/netbox/issues/13587) - Annotate units of measurement on power port table columns
+* [#13669](https://github.com/netbox-community/netbox/issues/13669) - Add bulk import button to contact assignments list view
+* [#13723](https://github.com/netbox-community/netbox/issues/13723) - Add inventory items column to interfaces table
+* [#13743](https://github.com/netbox-community/netbox/issues/13743) - Add site column to power feeds table
+* [#13936](https://github.com/netbox-community/netbox/issues/13936) - Add primary IPv4 and IPv6 filters for virtual machines and VDCs
+* [#13951](https://github.com/netbox-community/netbox/issues/13951) - Add device & virtual machine fields to service filter form
+* [#14085](https://github.com/netbox-community/netbox/issues/14085) - Strip trailing port number from value returned by `get_client_ip()`
+* [#14101](https://github.com/netbox-community/netbox/issues/14101) - Add greater/less than mask length filters for IP addresses
+* [#14112](https://github.com/netbox-community/netbox/issues/14112) - Add tab listing child items under inventory item view
+* [#14113](https://github.com/netbox-community/netbox/issues/14113) - Add optional parent column to inventory items table
+* [#14220](https://github.com/netbox-community/netbox/issues/14220) - Order available columns alphabetically in table configuration form
+* [#14221](https://github.com/netbox-community/netbox/issues/14221) - Add contact group column on contact assignments table
+
+### Bug Fixes
+
+* [#14033](https://github.com/netbox-community/netbox/issues/14033) - Avoid exception when attempting to connect both ends of a cable to the same object
+* [#14117](https://github.com/netbox-community/netbox/issues/14117) - Check that enough rear port positions have been selected to accommodate the number of front ports being created
+* [#14166](https://github.com/netbox-community/netbox/issues/14166) - Permit user login when maintenance mode is enabled
+* [#14182](https://github.com/netbox-community/netbox/issues/14182) - Ensure the active configuration is restored upon clearing cache
+* [#14195](https://github.com/netbox-community/netbox/issues/14195) - Correct permissions evaluation for ASN range child ASNs view
+* [#14223](https://github.com/netbox-community/netbox/issues/14223) - Disable ordering of jobs by assigned object
+
+---
+
 ## v3.6.4 (2023-10-17)
 ## v3.6.4 (2023-10-17)
 
 
 ### Enhancements
 ### Enhancements

+ 9 - 0
netbox/core/management/commands/clearcache.py

@@ -1,11 +1,20 @@
 from django.core.cache import cache
 from django.core.cache import cache
 from django.core.management.base import BaseCommand
 from django.core.management.base import BaseCommand
 
 
+from extras.models import ConfigRevision
+
 
 
 class Command(BaseCommand):
 class Command(BaseCommand):
     """Command to clear the entire cache."""
     """Command to clear the entire cache."""
     help = 'Clears the cache.'
     help = 'Clears the cache.'
 
 
     def handle(self, *args, **kwargs):
     def handle(self, *args, **kwargs):
+        # Fetch the current config revision from the cache
+        config_version = cache.get('config_version')
+        # Clear the cache
         cache.clear()
         cache.clear()
         self.stdout.write('Cache has been cleared.', ending="\n")
         self.stdout.write('Cache has been cleared.', ending="\n")
+        if config_version:
+            # Activate the current config revision
+            ConfigRevision.objects.get(id=config_version).activate()
+            self.stdout.write(f'Config revision ({config_version}) has been restored.', ending="\n")

+ 2 - 1
netbox/core/tables/jobs.py

@@ -19,7 +19,8 @@ class JobTable(NetBoxTable):
     )
     )
     object = tables.Column(
     object = tables.Column(
         verbose_name=_('Object'),
         verbose_name=_('Object'),
-        linkify=True
+        linkify=True,
+        orderable=False
     )
     )
     status = columns.ChoiceFieldColumn(
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status'),
         verbose_name=_('Status'),

+ 9 - 12
netbox/dcim/filtersets.py

@@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
 
 
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, L2VPN, IPAddress, VRF
 from ipam.models import ASN, L2VPN, IPAddress, VRF
 from netbox.filtersets import (
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
         fields = ['id', 'name', 'slug', 'description']
 
 
 
 
-class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(
+    NetBoxModelFilterSet,
+    TenancyFilterSet,
+    ContactModelFilterSet,
+    LocalConfigContextFilterSet,
+    PrimaryIPFilterSet,
+):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device_type__manufacturer',
         field_name='device_type__manufacturer',
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
@@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         method='_device_bays',
         method='_device_bays',
         label=_('Has device bays'),
         label=_('Has device bays'),
     )
     )
-    primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='primary_ip4',
-        queryset=IPAddress.objects.all(),
-        label=_('Primary IPv4 (ID)'),
-    )
-    primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='primary_ip6',
-        queryset=IPAddress.objects.all(),
-        label=_('Primary IPv6 (ID)'),
-    )
     oob_ip_id = django_filters.ModelMultipleChoiceFilter(
     oob_ip_id = django_filters.ModelMultipleChoiceFilter(
         field_name='oob_ip',
         field_name='oob_ip',
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
@@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         return queryset.exclude(devicebays__isnull=value)
         return queryset.exclude(devicebays__isnull=value)
 
 
 
 
-class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(
     device_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device',
         field_name='device',
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),

+ 2 - 1
netbox/dcim/forms/model_forms.py

@@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
     platform = DynamicModelChoiceField(
     platform = DynamicModelChoiceField(
         label=_('Platform'),
         label=_('Platform'),
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
-        required=False
+        required=False,
+        selector=True
     )
     )
     cluster = DynamicModelChoiceField(
     cluster = DynamicModelChoiceField(
         label=_('Cluster'),
         label=_('Cluster'),

+ 33 - 0
netbox/dcim/forms/object_create.py

@@ -151,6 +151,23 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp
                     )
                     )
         self.fields['rear_port'].choices = choices
         self.fields['rear_port'].choices = choices
 
 
+    def clean(self):
+
+        # Check that the number of FrontPortTemplates to be created matches the selected number of RearPortTemplate
+        # positions
+        frontport_count = len(self.cleaned_data['name'])
+        rearport_count = len(self.cleaned_data['rear_port'])
+        if frontport_count != rearport_count:
+            raise forms.ValidationError({
+                'rear_port': _(
+                    "The number of front port templates to be created ({frontport_count}) must match the selected "
+                    "number of rear port positions ({rearport_count})."
+                ).format(
+                    frontport_count=frontport_count,
+                    rearport_count=rearport_count
+                )
+            })
+
     def get_iterative_data(self, iteration):
     def get_iterative_data(self, iteration):
 
 
         # Assign rear port and position from selected set
         # Assign rear port and position from selected set
@@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
                     )
                     )
         self.fields['rear_port'].choices = choices
         self.fields['rear_port'].choices = choices
 
 
+    def clean(self):
+
+        # Check that the number of FrontPorts to be created matches the selected number of RearPort positions
+        frontport_count = len(self.cleaned_data['name'])
+        rearport_count = len(self.cleaned_data['rear_port'])
+        if frontport_count != rearport_count:
+            raise forms.ValidationError({
+                'rear_port': _(
+                    "The number of front ports to be created ({frontport_count}) must match the selected number of "
+                    "rear port positions ({rearport_count})."
+                ).format(
+                    frontport_count=frontport_count,
+                    rearport_count=rearport_count
+                )
+            })
+
     def get_iterative_data(self, iteration):
     def get_iterative_data(self, iteration):
 
 
         # Assign rear port and position from selected set
         # Assign rear port and position from selected set

+ 11 - 0
netbox/dcim/models/cables.py

@@ -180,6 +180,17 @@ class Cable(PrimaryModel):
                 if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
                 if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
                     raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
                     raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
 
 
+                if a_type == b_type:
+                    # can't directly use self.a_terminations here as possible they
+                    # don't have pk yet
+                    a_pks = set(obj.pk for obj in self.a_terminations if obj.pk)
+                    b_pks = set(obj.pk for obj in self.b_terminations if obj.pk)
+
+                    if (a_pks & b_pks):
+                        raise ValidationError(
+                            _("A and B terminations cannot connect to the same object.")
+                        )
+
             # Run clean() on any new CableTerminations
             # Run clean() on any new CableTerminations
             for termination in self.a_terminations:
             for termination in self.a_terminations:
                 CableTermination(cable=self, cable_end='A', termination=termination).clean()
                 CableTermination(cable=self, cable_end='A', termination=termination).clean()

+ 16 - 2
netbox/dcim/tables/devices.py

@@ -466,6 +466,12 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
             'args': [Accessor('device_id')],
             'args': [Accessor('device_id')],
         }
         }
     )
     )
+    maximum_draw = tables.Column(
+        verbose_name=_('Maximum draw (W)')
+    )
+    allocated_draw = tables.Column(
+        verbose_name=_('Allocated draw (W)')
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:powerport_list'
         url_name='dcim:powerport_list'
     )
     )
@@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         verbose_name=_('VRF'),
         verbose_name=_('VRF'),
         linkify=True
         linkify=True
     )
     )
+    inventory_items = tables.ManyToManyColumn(
+        linkify_item=True,
+        verbose_name=_('Inventory Items'),
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:interface_list'
         url_name='dcim:interface_list'
     )
     )
@@ -636,7 +646,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
             'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
             'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
             'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
             'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
-            'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
+            'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'inventory_items', 'created', 'last_updated',
         )
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
 
 
@@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
     discovered = columns.BooleanColumn(
     discovered = columns.BooleanColumn(
         verbose_name=_('Discovered'),
         verbose_name=_('Discovered'),
     )
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name=_('Parent'),
+    )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:inventoryitem_list'
         url_name='dcim:inventoryitem_list'
     )
     )
@@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = models.InventoryItem
         model = models.InventoryItem
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
+            'pk', 'id', 'name', 'device', 'parent', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
             'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
             'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (

+ 8 - 3
netbox/dcim/tables/power.py

@@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
         linkify=True,
         linkify=True,
         verbose_name=_('Tenant')
         verbose_name=_('Tenant')
     )
     )
+    site = tables.Column(
+        accessor='rack__site',
+        linkify=True,
+        verbose_name=_('Site'),
+    )
     comments = columns.MarkdownColumn(
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
     )
     )
@@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = PowerFeed
         model = PowerFeed
         fields = (
         fields = (
-            'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
-            'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
-            'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'power_panel', 'site', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage',
+            'phase', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
+            'tenant', 'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
             'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

+ 20 - 0
netbox/dcim/tests/test_filtersets.py

@@ -4712,12 +4712,18 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         addresses = (
         addresses = (
             IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
             IPAddress(assigned_object=interfaces[0], address='10.1.1.1/24'),
             IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
             IPAddress(assigned_object=interfaces[1], address='10.1.1.2/24'),
+            IPAddress(assigned_object=None, address='10.1.1.3/24'),
+            IPAddress(assigned_object=interfaces[0], address='2001:db8::1/64'),
+            IPAddress(assigned_object=interfaces[1], address='2001:db8::2/64'),
+            IPAddress(assigned_object=None, address='2001:db8::3/64'),
         )
         )
         IPAddress.objects.bulk_create(addresses)
         IPAddress.objects.bulk_create(addresses)
 
 
         vdcs[0].primary_ip4 = addresses[0]
         vdcs[0].primary_ip4 = addresses[0]
+        vdcs[0].primary_ip6 = addresses[3]
         vdcs[0].save()
         vdcs[0].save()
         vdcs[1].primary_ip4 = addresses[1]
         vdcs[1].primary_ip4 = addresses[1]
+        vdcs[1].primary_ip6 = addresses[4]
         vdcs[1].save()
         vdcs[1].save()
 
 
     def test_device(self):
     def test_device(self):
@@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'has_primary_ip': False}
         params = {'has_primary_ip': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_primary_ip4(self):
+        addresses = IPAddress.objects.filter(address__family=4)
+        params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip4_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+    def test_primary_ip6(self):
+        addresses = IPAddress.objects.filter(address__family=6)
+        params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip6_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)

+ 19 - 0
netbox/dcim/views.py

@@ -2993,6 +2993,25 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
 
 
 
 
+@register_model_view(InventoryItem, 'children')
+class InventoryItemChildrenView(generic.ObjectChildrenView):
+    queryset = InventoryItem.objects.all()
+    child_model = InventoryItem
+    table = tables.InventoryItemTable
+    filterset = filtersets.InventoryItemFilterSet
+    template_name = 'generic/object_children.html'
+    tab = ViewTab(
+        label=_('Children'),
+        badge=lambda obj: obj.child_items.count(),
+        permission='dcim.view_inventoryitem',
+        hide_if_empty=True,
+        weight=5000
+    )
+
+    def get_children(self, request, parent):
+        return parent.child_items.restrict(request.user, 'view')
+
+
 #
 #
 # Inventory item roles
 # Inventory item roles
 #
 #

+ 1 - 1
netbox/extras/tests/test_views.py

@@ -457,7 +457,7 @@ class ConfigContextTestCase(
             'platforms': [],
             'platforms': [],
             'tenant_groups': [],
             'tenant_groups': [],
             'tenants': [],
             'tenants': [],
-            'device_types': [devicetype.id,],
+            'device_types': [devicetype.id],
             'tags': [],
             'tags': [],
             'data': '{"foo": 123}',
             'data': '{"foo": 123}',
         }
         }

+ 31 - 9
netbox/ipam/filtersets.py

@@ -29,6 +29,7 @@ __all__ = (
     'L2VPNFilterSet',
     'L2VPNFilterSet',
     'L2VPNTerminationFilterSet',
     'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
     'PrefixFilterSet',
+    'PrimaryIPFilterSet',
     'RIRFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     'RoleFilterSet',
     'RouteTargetFilterSet',
     'RouteTargetFilterSet',
@@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     )
     )
     mask_length = MultiValueNumberFilter(
     mask_length = MultiValueNumberFilter(
         field_name='prefix',
         field_name='prefix',
-        lookup_expr='net_mask_length'
+        lookup_expr='net_mask_length',
+        label=_('Mask length')
     )
     )
     mask_length__gte = django_filters.NumberFilter(
     mask_length__gte = django_filters.NumberFilter(
         field_name='prefix',
         field_name='prefix',
@@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         method='filter_address',
         method='filter_address',
         label=_('Address'),
         label=_('Address'),
     )
     )
-    mask_length = django_filters.NumberFilter(
-        method='filter_mask_length',
-        label=_('Mask length'),
+    mask_length = MultiValueNumberFilter(
+        field_name='address',
+        lookup_expr='net_mask_length',
+        label=_('Mask length')
+    )
+    mask_length__gte = django_filters.NumberFilter(
+        field_name='address',
+        lookup_expr='net_mask_length__gte'
+    )
+    mask_length__lte = django_filters.NumberFilter(
+        field_name='address',
+        lookup_expr='net_mask_length__lte'
     )
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
     vrf_id = django_filters.ModelMultipleChoiceFilter(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
@@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         except ValidationError:
         except ValidationError:
             return queryset.none()
             return queryset.none()
 
 
-    def filter_mask_length(self, queryset, name, value):
-        if not value:
-            return queryset
-        return queryset.filter(address__net_mask_length=value)
-
     @extend_schema_field(OpenApiTypes.STR)
     @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
         if vrf is None:
@@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
             )
             )
         )
         )
         return qs
         return qs
+
+
+class PrimaryIPFilterSet(django_filters.FilterSet):
+    """
+    An inheritable FilterSet for models which support primary IP assignment.
+    """
+    primary_ip4_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='primary_ip4',
+        queryset=IPAddress.objects.all(),
+        label=_('Primary IPv4 (ID)'),
+    )
+    primary_ip6_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='primary_ip6',
+        queryset=IPAddress.objects.all(),
+        label=_('Primary IPv6 (ID)'),
+    )

+ 19 - 1
netbox/ipam/forms/bulk_import.py

@@ -507,10 +507,28 @@ class ServiceImportForm(NetBoxModelImportForm):
         choices=ServiceProtocolChoices,
         choices=ServiceProtocolChoices,
         help_text=_('IP protocol')
         help_text=_('IP protocol')
     )
     )
+    ipaddresses = CSVModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        to_field_name='address',
+        help_text=_('IP Address'),
+    )
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description', 'comments', 'tags')
+        fields = (
+            'device', 'virtual_machine', 'ipaddresses', 'name', 'protocol', 'ports', 'description', 'comments', 'tags',
+        )
+
+    def clean_ipaddresses(self):
+        parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
+        for ip_address in self.cleaned_data['ipaddresses']:
+            if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
+                raise forms.ValidationError(
+                    _("{ip} is not assigned to this device/VM.").format(ip=ip_address)
+                )
+
+        return self.cleaned_data['ipaddresses']
 
 
 
 
 class L2VPNImportForm(NetBoxModelImportForm):
 class L2VPNImportForm(NetBoxModelImportForm):

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

@@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
 class ServiceFilterForm(ServiceTemplateFilterForm):
     model = Service
     model = Service
+    fieldsets = (
+        (None, ('q', 'filter_id', 'tag')),
+        (_('Attributes'), ('protocol', 'port')),
+        (_('Assignment'), ('device_id', 'virtual_machine_id')),
+    )
+    device_id = DynamicModelMultipleChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        label=_('Device'),
+    )
+    virtual_machine_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        label=_('Virtual Machine'),
+    )
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 

+ 10 - 2
netbox/ipam/tests/test_filtersets.py

@@ -627,8 +627,12 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_mask_length(self):
     def test_mask_length(self):
-        params = {'mask_length': ['24']}
+        params = {'mask_length': [24]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'mask_length__gte': 32}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        params = {'mask_length__lte': 24}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
 
 
     def test_vrf(self):
     def test_vrf(self):
         vrfs = VRF.objects.all()[:2]
         vrfs = VRF.objects.all()[:2]
@@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_mask_length(self):
     def test_mask_length(self):
-        params = {'mask_length': '24'}
+        params = {'mask_length': [24]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
+        params = {'mask_length__gte': 64}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+        params = {'mask_length__lte': 25}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
 
     def test_vrf(self):
     def test_vrf(self):
         vrfs = VRF.objects.all()[:2]
         vrfs = VRF.objects.all()[:2]

+ 12 - 4
netbox/ipam/tests/test_views.py

@@ -4,6 +4,7 @@ from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
+from dcim.constants import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
 from ipam.choices import *
 from ipam.choices import *
 from ipam.models import *
 from ipam.models import *
@@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
         device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
         device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
+        interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
 
 
         services = (
         services = (
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
@@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         )
         Service.objects.bulk_create(services)
         Service.objects.bulk_create(services)
 
 
+        ip_addresses = (
+            IPAddress(assigned_object=interface, address='192.0.2.1/24'),
+            IPAddress(assigned_object=interface, address='192.0.2.2/24'),
+        )
+        IPAddress.objects.bulk_create(ip_addresses)
+
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
@@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         cls.csv_data = (
-            "device,name,protocol,ports,description",
-            "Device 1,Service 1,tcp,1,First service",
-            "Device 1,Service 2,tcp,2,Second service",
-            "Device 1,Service 3,udp,3,Third service",
+            "device,name,protocol,ports,ipaddresses,description",
+            "Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
+            "Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
+            "Device 1,Service 3,udp,3,,Third service",
         )
         )
 
 
         cls.csv_update_data = (
         cls.csv_update_data = (

+ 1 - 1
netbox/ipam/views.py

@@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('ASNs'),
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
         badge=lambda x: x.get_child_asns().count(),
-        permission='ipam.view_asns',
+        permission='ipam.view_asn',
         weight=500
         weight=500
     )
     )
 
 

+ 4 - 1
netbox/netbox/settings.py

@@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.6.4'
+VERSION = '3.6.5'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
 MAINTENANCE_EXEMPT_PATHS = (
 MAINTENANCE_EXEMPT_PATHS = (
     f'/{BASE_PATH}admin/',
     f'/{BASE_PATH}admin/',
     f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
     f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
+    LOGIN_URL,
+    LOGIN_REDIRECT_URL,
+    LOGOUT_REDIRECT_URL
 )
 )
 
 
 SERIALIZATION_MODULES = {
 SERIALIZATION_MODULES = {

+ 1 - 1
netbox/netbox/tables/tables.py

@@ -119,7 +119,7 @@ class BaseTable(tables.Table):
 
 
     @property
     @property
     def available_columns(self):
     def available_columns(self):
-        return self._get_columns(visible=False)
+        return sorted(self._get_columns(visible=False))
 
 
     @property
     @property
     def selected_columns(self):
     def selected_columns(self):

+ 7 - 1
netbox/tenancy/tables/contacts.py

@@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
         verbose_name=_('Role'),
         verbose_name=_('Role'),
         linkify=True
         linkify=True
     )
     )
+    contact_group = tables.Column(
+        accessor=Accessor('contact__group'),
+        verbose_name=_('Group'),
+        linkify=True
+    )
     contact_title = tables.Column(
     contact_title = tables.Column(
         accessor=Accessor('contact__title'),
         accessor=Accessor('contact__title'),
         verbose_name=_('Contact Title')
         verbose_name=_('Contact Title')
@@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
         model = ContactAssignment
         model = ContactAssignment
         fields = (
         fields = (
             'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
             'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_title', 'contact_phone',
-            'contact_email', 'contact_address', 'contact_link', 'contact_description', 'tags', 'actions'
+            'contact_email', 'contact_address', 'contact_link', 'contact_description', 'contact_group', 'tags',
+            'actions'
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'
             'pk', 'content_type', 'object', 'contact', 'role', 'priority', 'contact_email', 'contact_phone'

+ 1 - 1
netbox/tenancy/views.py

@@ -386,7 +386,7 @@ class ContactAssignmentListView(generic.ObjectListView):
     filterset = filtersets.ContactAssignmentFilterSet
     filterset = filtersets.ContactAssignmentFilterSet
     filterset_form = forms.ContactAssignmentFilterForm
     filterset_form = forms.ContactAssignmentFilterForm
     table = tables.ContactAssignmentTable
     table = tables.ContactAssignmentTable
-    actions = ('export', 'bulk_edit', 'bulk_delete')
+    actions = ('export', 'bulk_edit', 'bulk_delete', 'import')
 
 
 
 
 @register_model_view(ContactAssignment, 'edit')
 @register_model_view(ContactAssignment, 'edit')

+ 1 - 1
netbox/utilities/request.py

@@ -17,7 +17,7 @@ def get_client_ip(request, additional_headers=()):
     )
     )
     for header in HTTP_HEADERS:
     for header in HTTP_HEADERS:
         if header in request.META:
         if header in request.META:
-            client_ip = request.META[header].split(',')[0]
+            client_ip = request.META[header].split(',')[0].partition(':')[0]
             try:
             try:
                 return IPAddress(client_ip)
                 return IPAddress(client_ip)
             except ValueError:
             except ValueError:

+ 3 - 1
netbox/virtualization/filtersets.py

@@ -6,6 +6,7 @@ from dcim.filtersets import CommonInterfaceFilterSet
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from extras.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -114,7 +115,8 @@ class VirtualMachineFilterSet(
     NetBoxModelFilterSet,
     NetBoxModelFilterSet,
     TenancyFilterSet,
     TenancyFilterSet,
     ContactModelFilterSet,
     ContactModelFilterSet,
-    LocalConfigContextFilterSet
+    LocalConfigContextFilterSet,
+    PrimaryIPFilterSet,
 ):
 ):
     status = django_filters.MultipleChoiceFilter(
     status = django_filters.MultipleChoiceFilter(
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,

+ 2 - 1
netbox/virtualization/forms/model_forms.py

@@ -200,7 +200,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
     platform = DynamicModelChoiceField(
     platform = DynamicModelChoiceField(
         label=_('Platform'),
         label=_('Platform'),
         queryset=Platform.objects.all(),
         queryset=Platform.objects.all(),
-        required=False
+        required=False,
+        selector=True
     )
     )
     local_context_data = JSONField(
     local_context_data = JSONField(
         required=False,
         required=False,

+ 20 - 2
netbox/virtualization/tests/test_filtersets.py

@@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         ipaddresses = (
         ipaddresses = (
             IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
             IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
             IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
             IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
+            IPAddress(address='192.0.2.3/24', assigned_object=None),
+            IPAddress(address='2001:db8::1/64', assigned_object=interfaces[0]),
+            IPAddress(address='2001:db8::2/64', assigned_object=interfaces[1]),
+            IPAddress(address='2001:db8::3/64', assigned_object=None),
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         IPAddress.objects.bulk_create(ipaddresses)
-        VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0])
-        VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1])
+        VirtualMachine.objects.filter(pk=vms[0].pk).update(primary_ip4=ipaddresses[0], primary_ip6=ipaddresses[3])
+        VirtualMachine.objects.filter(pk=vms[1].pk).update(primary_ip4=ipaddresses[1], primary_ip6=ipaddresses[4])
 
 
     def test_name(self):
     def test_name(self):
         params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
         params = {'name': ['Virtual Machine 1', 'Virtual Machine 2']}
@@ -412,6 +416,20 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_primary_ip4(self):
+        addresses = IPAddress.objects.filter(address__family=4)
+        params = {'primary_ip4_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip4_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+    def test_primary_ip6(self):
+        addresses = IPAddress.objects.filter(address__family=6)
+        params = {'primary_ip6_id': [addresses[0].pk, addresses[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'primary_ip6_id': [addresses[2].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
 
 
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()

+ 4 - 4
requirements.txt

@@ -1,5 +1,5 @@
 bleach==6.1.0
 bleach==6.1.0
-Django==4.2.6
+Django==4.2.7
 django-cors-headers==4.3.0
 django-cors-headers==4.3.0
 django-debug-toolbar==4.2.0
 django-debug-toolbar==4.2.0
 django-filter==23.3
 django-filter==23.3
@@ -21,16 +21,16 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.4.6
+mkdocs-material==9.4.8
 mkdocstrings[python-legacy]==0.23.0
 mkdocstrings[python-legacy]==0.23.0
 netaddr==0.9.0
 netaddr==0.9.0
 Pillow==10.1.0
 Pillow==10.1.0
 psycopg[binary,pool]==3.1.12
 psycopg[binary,pool]==3.1.12
 PyYAML==6.0.1
 PyYAML==6.0.1
 requests==2.31.0
 requests==2.31.0
-sentry-sdk==1.32.0
+sentry-sdk==1.34.0
 social-auth-app-django==5.4.0
 social-auth-app-django==5.4.0
-social-auth-core[openidconnect]==4.4.2
+social-auth-core[openidconnect]==4.5.0
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.5.0
 tablib==3.5.0
 tzdata==2023.3
 tzdata==2023.3