Просмотр исходного кода

Merge pull request #14238 from netbox-community/develop

Release v3.6.5
Jeremy Stretch 2 лет назад
Родитель
Сommit
6ac25eeb65

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

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

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

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.6.4
+      placeholder: v3.6.5
     validations:
       required: true
   - 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
 # 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
 # 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",
       "op": "contains"
     }

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

@@ -1,5 +1,35 @@
 # 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)
 
 ### Enhancements

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

@@ -1,11 +1,20 @@
 from django.core.cache import cache
 from django.core.management.base import BaseCommand
 
+from extras.models import ConfigRevision
+
 
 class Command(BaseCommand):
     """Command to clear the entire cache."""
     help = 'Clears the cache.'
 
     def handle(self, *args, **kwargs):
+        # Fetch the current config revision from the cache
+        config_version = cache.get('config_version')
+        # Clear the cache
         cache.clear()
         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(
         verbose_name=_('Object'),
-        linkify=True
+        linkify=True,
+        orderable=False
     )
     status = columns.ChoiceFieldColumn(
         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.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
 from ipam.models import ASN, L2VPN, IPAddress, VRF
 from netbox.filtersets import (
     BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
@@ -817,7 +818,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         fields = ['id', 'name', 'slug', 'description']
 
 
-class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet):
+class DeviceFilterSet(
+    NetBoxModelFilterSet,
+    TenancyFilterSet,
+    ContactModelFilterSet,
+    LocalConfigContextFilterSet,
+    PrimaryIPFilterSet,
+):
     manufacturer_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device_type__manufacturer',
         queryset=Manufacturer.objects.all(),
@@ -993,16 +1000,6 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         method='_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(
         field_name='oob_ip',
         queryset=IPAddress.objects.all(),
@@ -1069,7 +1066,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
         return queryset.exclude(devicebays__isnull=value)
 
 
-class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
     device_id = django_filters.ModelMultipleChoiceFilter(
         field_name='device',
         queryset=Device.objects.all(),

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

@@ -442,7 +442,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
     platform = DynamicModelChoiceField(
         label=_('Platform'),
         queryset=Platform.objects.all(),
-        required=False
+        required=False,
+        selector=True
     )
     cluster = DynamicModelChoiceField(
         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
 
+    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):
 
         # Assign rear port and position from selected set
@@ -291,6 +308,22 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
                     )
         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):
 
         # 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):
                     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
             for termination in self.a_terminations:
                 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')],
         }
     )
+    maximum_draw = tables.Column(
+        verbose_name=_('Maximum draw (W)')
+    )
+    allocated_draw = tables.Column(
+        verbose_name=_('Allocated draw (W)')
+    )
     tags = columns.TagColumn(
         url_name='dcim:powerport_list'
     )
@@ -625,6 +631,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
         verbose_name=_('VRF'),
         linkify=True
     )
+    inventory_items = tables.ManyToManyColumn(
+        linkify_item=True,
+        verbose_name=_('Inventory Items'),
+    )
     tags = columns.TagColumn(
         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',
             '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',
-            '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')
 
@@ -933,6 +943,10 @@ class InventoryItemTable(DeviceComponentTable):
     discovered = columns.BooleanColumn(
         verbose_name=_('Discovered'),
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name=_('Parent'),
+    )
     tags = columns.TagColumn(
         url_name='dcim:inventoryitem_list'
     )
@@ -941,7 +955,7 @@ class InventoryItemTable(DeviceComponentTable):
     class Meta(NetBoxTable.Meta):
         model = models.InventoryItem
         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',
         )
         default_columns = (

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

@@ -87,6 +87,11 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
         linkify=True,
         verbose_name=_('Tenant')
     )
+    site = tables.Column(
+        accessor='rack__site',
+        linkify=True,
+        verbose_name=_('Site'),
+    )
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
     )
@@ -97,9 +102,9 @@ class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
     class Meta(NetBoxTable.Meta):
         model = PowerFeed
         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 = (
             '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 = (
             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=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)
 
         vdcs[0].primary_ip4 = addresses[0]
+        vdcs[0].primary_ip6 = addresses[3]
         vdcs[0].save()
         vdcs[1].primary_ip4 = addresses[1]
+        vdcs[1].primary_ip6 = addresses[4]
         vdcs[1].save()
 
     def test_device(self):
@@ -4738,3 +4744,17 @@ class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'has_primary_ip': False}
         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'
 
 
+@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
 #

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

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

+ 31 - 9
netbox/ipam/filtersets.py

@@ -29,6 +29,7 @@ __all__ = (
     'L2VPNFilterSet',
     'L2VPNTerminationFilterSet',
     'PrefixFilterSet',
+    'PrimaryIPFilterSet',
     'RIRFilterSet',
     'RoleFilterSet',
     'RouteTargetFilterSet',
@@ -266,7 +267,8 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
     )
     mask_length = MultiValueNumberFilter(
         field_name='prefix',
-        lookup_expr='net_mask_length'
+        lookup_expr='net_mask_length',
+        label=_('Mask length')
     )
     mask_length__gte = django_filters.NumberFilter(
         field_name='prefix',
@@ -531,9 +533,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         method='filter_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(
         queryset=VRF.objects.all(),
@@ -677,11 +688,6 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
         except ValidationError:
             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)
     def filter_present_in_vrf(self, queryset, name, vrf):
         if vrf is None:
@@ -1227,3 +1233,19 @@ class L2VPNTerminationFilterSet(NetBoxModelFilterSet):
             )
         )
         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,
         help_text=_('IP protocol')
     )
+    ipaddresses = CSVModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        to_field_name='address',
+        help_text=_('IP Address'),
+    )
 
     class Meta:
         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):

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

@@ -523,6 +523,21 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
     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)
 
 

+ 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)
 
     def test_mask_length(self):
-        params = {'mask_length': ['24']}
+        params = {'mask_length': [24]}
         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):
         vrfs = VRF.objects.all()[:2]
@@ -954,8 +958,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_mask_length(self):
-        params = {'mask_length': '24'}
+        params = {'mask_length': [24]}
         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):
         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 netaddr import IPNetwork
 
+from dcim.constants import InterfaceTypeChoices
 from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface
 from ipam.choices import *
 from ipam.models import *
@@ -911,6 +912,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 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)
+        interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
 
         services = (
             Service(device=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
@@ -919,6 +921,12 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         )
         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')
 
         cls.form_data = {
@@ -933,10 +941,10 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         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 = (

+ 1 - 1
netbox/ipam/views.py

@@ -220,7 +220,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
     tab = ViewTab(
         label=_('ASNs'),
         badge=lambda x: x.get_child_asns().count(),
-        permission='ipam.view_asns',
+        permission='ipam.view_asn',
         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
 #
 
-VERSION = '3.6.4'
+VERSION = '3.6.5'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -502,6 +502,9 @@ AUTH_EXEMPT_PATHS = (
 MAINTENANCE_EXEMPT_PATHS = (
     f'/{BASE_PATH}admin/',
     f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
+    LOGIN_URL,
+    LOGIN_REDIRECT_URL,
+    LOGOUT_REDIRECT_URL
 )
 
 SERIALIZATION_MODULES = {

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

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

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

@@ -102,6 +102,11 @@ class ContactAssignmentTable(NetBoxTable):
         verbose_name=_('Role'),
         linkify=True
     )
+    contact_group = tables.Column(
+        accessor=Accessor('contact__group'),
+        verbose_name=_('Group'),
+        linkify=True
+    )
     contact_title = tables.Column(
         accessor=Accessor('contact__title'),
         verbose_name=_('Contact Title')
@@ -137,7 +142,8 @@ class ContactAssignmentTable(NetBoxTable):
         model = ContactAssignment
         fields = (
             '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 = (
             '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_form = forms.ContactAssignmentFilterForm
     table = tables.ContactAssignmentTable
-    actions = ('export', 'bulk_edit', 'bulk_delete')
+    actions = ('export', 'bulk_edit', 'bulk_delete', 'import')
 
 
 @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:
         if header in request.META:
-            client_ip = request.META[header].split(',')[0]
+            client_ip = request.META[header].split(',')[0].partition(':')[0]
             try:
                 return IPAddress(client_ip)
             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 extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
+from ipam.filtersets import PrimaryIPFilterSet
 from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet
 from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
 from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter
@@ -114,7 +115,8 @@ class VirtualMachineFilterSet(
     NetBoxModelFilterSet,
     TenancyFilterSet,
     ContactModelFilterSet,
-    LocalConfigContextFilterSet
+    LocalConfigContextFilterSet,
+    PrimaryIPFilterSet,
 ):
     status = django_filters.MultipleChoiceFilter(
         choices=VirtualMachineStatusChoices,

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

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

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

@@ -291,10 +291,14 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests):
         ipaddresses = (
             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.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)
-        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):
         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]}
         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):
     queryset = VMInterface.objects.all()

+ 4 - 4
requirements.txt

@@ -1,5 +1,5 @@
 bleach==6.1.0
-Django==4.2.6
+Django==4.2.7
 django-cors-headers==4.3.0
 django-debug-toolbar==4.2.0
 django-filter==23.3
@@ -21,16 +21,16 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==9.4.6
+mkdocs-material==9.4.8
 mkdocstrings[python-legacy]==0.23.0
 netaddr==0.9.0
 Pillow==10.1.0
 psycopg[binary,pool]==3.1.12
 PyYAML==6.0.1
 requests==2.31.0
-sentry-sdk==1.32.0
+sentry-sdk==1.34.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
 tablib==3.5.0
 tzdata==2023.3