Parcourir la source

Merge branch 'develop' into feature

Jeremy Stretch il y a 1 an
Parent
commit
78bd7dec48

+ 3 - 2
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,15 +17,16 @@ body:
         How are you running NetBox? (For issues with the Docker image, please go to the
         How are you running NetBox? (For issues with the Docker image, please go to the
         [netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
         [netbox-docker](https://github.com/netbox-community/netbox-docker) repo.)
       options:
       options:
-        - Self-hosted
         - NetBox Cloud
         - NetBox Cloud
+        - NetBox Enterprise
+        - Self-hosted
     validations:
     validations:
       required: true
       required: true
   - type: input
   - type: input
     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.7.3
+      placeholder: v3.7.4
     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.7.3
+      placeholder: v3.7.4
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/workflows/ci.yml

@@ -84,4 +84,4 @@ jobs:
       run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
       run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
 
 
     - name: Show coverage report
     - name: Show coverage report
-      run: coverage report --skip-covered --omit *migrations*
+      run: coverage report --skip-covered --omit '*/migrations/*,*/tests/*'

+ 1 - 1
base_requirements.txt

@@ -101,7 +101,7 @@ markdown-include
 mkdocs-material
 mkdocs-material
 
 
 # Introspection for embedded code
 # Introspection for embedded code
-# https://github.com/mkdocstrings/mkdocstrings/blob/master/CHANGELOG.md
+# https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md
 mkdocstrings[python-legacy]
 mkdocstrings[python-legacy]
 
 
 # Library for manipulating IP prefixes and addresses
 # Library for manipulating IP prefixes and addresses

+ 3 - 0
contrib/generated_schema.json

@@ -384,7 +384,10 @@
                         "8gfc-sfpp",
                         "8gfc-sfpp",
                         "16gfc-sfpp",
                         "16gfc-sfpp",
                         "32gfc-sfp28",
                         "32gfc-sfp28",
+                        "32gfc-sfpp",
                         "64gfc-qsfpp",
                         "64gfc-qsfpp",
+                        "64gfc-sfpdd",
+                        "64gfc-sfpp",
                         "128gfc-qsfp28",
                         "128gfc-qsfp28",
                         "infiniband-sdr",
                         "infiniband-sdr",
                         "infiniband-ddr",
                         "infiniband-ddr",

+ 1 - 2
docs/installation/1-postgresql.md

@@ -31,8 +31,7 @@ This section entails the installation and configuration of a local PostgreSQL da
     Once PostgreSQL has been installed, start the service and enable it to run at boot:
     Once PostgreSQL has been installed, start the service and enable it to run at boot:
 
 
     ```no-highlight
     ```no-highlight
-    sudo systemctl start postgresql
-    sudo systemctl enable postgresql
+    sudo systemctl enable --now postgresql
     ```
     ```
 
 
 Before continuing, verify that you have installed PostgreSQL 12 or later:
 Before continuing, verify that you have installed PostgreSQL 12 or later:

+ 1 - 2
docs/installation/2-redis.md

@@ -14,8 +14,7 @@
 
 
     ```no-highlight
     ```no-highlight
     sudo yum install -y redis
     sudo yum install -y redis
-    sudo systemctl start redis
-    sudo systemctl enable redis
+    sudo systemctl enable --now redis
     ```
     ```
 
 
 Before continuing, verify that your installed version of Redis is at least v4.0:
 Before continuing, verify that your installed version of Redis is at least v4.0:

+ 1 - 2
docs/installation/4-gunicorn.md

@@ -27,8 +27,7 @@ sudo systemctl daemon-reload
 Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
 Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
 
 
 ```no-highlight
 ```no-highlight
-sudo systemctl start netbox netbox-rq
-sudo systemctl enable netbox netbox-rq
+sudo systemctl enable --now netbox netbox-rq
 ```
 ```
 
 
 You can use the command `systemctl status netbox` to verify that the WSGI service is running:
 You can use the command `systemctl status netbox` to verify that the WSGI service is running:

+ 26 - 1
docs/release-notes/version-3.7.md

@@ -1,6 +1,31 @@
 # NetBox v3.7
 # NetBox v3.7
 
 
-## v3.7.4 (FUTURE)
+## v3.7.5 (FUTURE)
+
+---
+
+## v3.7.4 (2024-03-13)
+
+### Enhancements
+
+* [#14206](https://github.com/netbox-community/netbox/issues/14206) - Add additional FibreChannel SFP+ interface types
+* [#14366](https://github.com/netbox-community/netbox/issues/14366) - Enable custom links for config contexts & templates
+* [#15291](https://github.com/netbox-community/netbox/issues/15291) - Add tunnel termination buttons to VM interfaces table
+* [#15297](https://github.com/netbox-community/netbox/issues/15297) - Linkify platform column in device & virtual machine tables
+
+### Bug Fixes
+
+* [#13722](https://github.com/netbox-community/netbox/issues/13722) - Fix range expansion for comma-separated numerical values
+* [#14832](https://github.com/netbox-community/netbox/issues/14832) - Enable querying IP addresses for an FHRP group via GraphQL
+* [#15220](https://github.com/netbox-community/netbox/issues/15220) - Fix validation check when bulk editing the mask length of IP addresses
+* [#15232](https://github.com/netbox-community/netbox/issues/15232) - Permit user with sufficient permissions to assign an inventory item to a device type
+* [#15241](https://github.com/netbox-community/netbox/issues/15241) - Restore missing `display` field on VirtualDisk serialization in REST API
+* [#15243](https://github.com/netbox-community/netbox/issues/15243) - Correct representation of installed module when listing module bays using REST API brief mode
+* [#15316](https://github.com/netbox-community/netbox/issues/15316) - Fix selection of 3DES encryption for IKE & IPSec proposals
+* [#15322](https://github.com/netbox-community/netbox/issues/15322) - Add description field to YAML export for device & module types
+* [#15336](https://github.com/netbox-community/netbox/issues/15336) - Correct label for recurring scheduled jobs
+* [#15347](https://github.com/netbox-community/netbox/issues/15347) - Fix querying virtual machine contacts via GraphQL
+* [#15356](https://github.com/netbox-community/netbox/issues/15356) - Fix assignment of front & rear images to device types via REST API
 
 
 ---
 ---
 
 

+ 10 - 2
netbox/dcim/api/nested_serializers.py

@@ -309,6 +309,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
+
+    class Meta:
+        model = models.Module
+        fields = ['id', 'url', 'display', 'serial']
+
+
 class NestedModuleSerializer(WritableNestedSerializer):
 class NestedModuleSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
     device = NestedDeviceSerializer(read_only=True)
     device = NestedDeviceSerializer(read_only=True)
@@ -392,11 +400,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
 
 
 class NestedModuleBaySerializer(WritableNestedSerializer):
 class NestedModuleBaySerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
-    module = NestedModuleSerializer(required=False, read_only=True, allow_null=True)
+    installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = models.ModuleBay
         model = models.ModuleBay
-        fields = ['id', 'url', 'display', 'module', 'name']
+        fields = ['id', 'url', 'display', 'installed_module', 'name']
 
 
 
 
 class NestedDeviceBaySerializer(WritableNestedSerializer):
 class NestedDeviceBaySerializer(WritableNestedSerializer):

+ 2 - 2
netbox/dcim/api/serializers_/devicetypes.py

@@ -28,8 +28,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
-    front_image = serializers.URLField(allow_null=True, required=False)
-    rear_image = serializers.URLField(allow_null=True, required=False)
+    front_image = serializers.ImageField(required=False, allow_null=True)
+    rear_image = serializers.ImageField(required=False, allow_null=True)
 
 
     # Counter fields
     # Counter fields
     console_port_template_count = serializers.IntegerField(read_only=True)
     console_port_template_count = serializers.IntegerField(read_only=True)

+ 6 - 0
netbox/dcim/choices.py

@@ -889,7 +889,10 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
     TYPE_8GFC_SFP_PLUS = '8gfc-sfpp'
     TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
     TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
     TYPE_32GFC_SFP28 = '32gfc-sfp28'
     TYPE_32GFC_SFP28 = '32gfc-sfp28'
+    TYPE_32GFC_SFP_PLUS = '32gfc-sfpp'
     TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
     TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
+    TYPE_64GFC_SFP_DD = '64gfc-sfpdd'
+    TYPE_64GFC_SFP_PLUS = '64gfc-sfpp'
     TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
     TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
 
 
     # InfiniBand
     # InfiniBand
@@ -1058,7 +1061,10 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
                 (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'),
                 (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
                 (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'),
                 (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
                 (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'),
+                (TYPE_32GFC_SFP_PLUS, 'SFP+ (32GFC)'),
                 (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
                 (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'),
+                (TYPE_64GFC_SFP_DD, 'SFP-DD (64GFC)'),
+                (TYPE_64GFC_SFP_PLUS, 'SFP+ (64GFC)'),
                 (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
                 (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'),
             )
             )
         ),
         ),

+ 4 - 2
netbox/dcim/models/devices.py

@@ -229,15 +229,16 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'manufacturer': self.manufacturer.name,
             'manufacturer': self.manufacturer.name,
             'model': self.model,
             'model': self.model,
             'slug': self.slug,
             'slug': self.slug,
+            'description': self.description,
             'default_platform': self.default_platform.name if self.default_platform else None,
             'default_platform': self.default_platform.name if self.default_platform else None,
             'part_number': self.part_number,
             'part_number': self.part_number,
             'u_height': float(self.u_height),
             'u_height': float(self.u_height),
             'is_full_depth': self.is_full_depth,
             'is_full_depth': self.is_full_depth,
             'subdevice_role': self.subdevice_role,
             'subdevice_role': self.subdevice_role,
             'airflow': self.airflow,
             'airflow': self.airflow,
-            'comments': self.comments,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
+            'comments': self.comments,
         }
         }
 
 
         # Component templates
         # Component templates
@@ -415,9 +416,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
             'manufacturer': self.manufacturer.name,
             'manufacturer': self.manufacturer.name,
             'model': self.model,
             'model': self.model,
             'part_number': self.part_number,
             'part_number': self.part_number,
-            'comments': self.comments,
+            'description': self.description,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight': float(self.weight) if self.weight is not None else None,
             'weight_unit': self.weight_unit,
             'weight_unit': self.weight_unit,
+            'comments': self.comments,
         }
         }
 
 
         # Component templates
         # Component templates

+ 5 - 1
netbox/dcim/tables/devices.py

@@ -210,6 +210,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         linkify=True,
         linkify=True,
         verbose_name=_('Type')
         verbose_name=_('Type')
     )
     )
+    platform = tables.Column(
+        linkify=True,
+        verbose_name=_('Platform')
+    )
     primary_ip = tables.Column(
     primary_ip = tables.Column(
         linkify=True,
         linkify=True,
         order_by=('primary_ip4', 'primary_ip6'),
         order_by=('primary_ip4', 'primary_ip6'),
@@ -294,7 +298,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
         model = models.Device
         model = models.Device
         fields = (
         fields = (
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
             'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'role', 'manufacturer', 'device_type',
-            'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
+            'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device',
             'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
             'device_bay_position', 'position', 'face', 'latitude', 'longitude', 'airflow', 'primary_ip', 'primary_ip4',
             'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
             'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
             'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',

+ 1 - 1
netbox/dcim/views.py

@@ -1079,7 +1079,7 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
     tab = ViewTab(
     tab = ViewTab(
         label=_('Inventory Items'),
         label=_('Inventory Items'),
         badge=lambda obj: obj.inventory_item_template_count,
         badge=lambda obj: obj.inventory_item_template_count,
-        permission='dcim.view_invenotryitemtemplate',
+        permission='dcim.view_inventoryitemtemplate',
         weight=590,
         weight=590,
         hide_if_empty=True
         hide_if_empty=True
     )
     )

+ 1 - 0
netbox/extras/graphql/mixins.py

@@ -7,6 +7,7 @@ from extras.models import ObjectChange
 __all__ = (
 __all__ = (
     'ChangelogMixin',
     'ChangelogMixin',
     'ConfigContextMixin',
     'ConfigContextMixin',
+    'ContactsMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
     'ImageAttachmentsMixin',
     'ImageAttachmentsMixin',
     'JournalEntriesMixin',
     'JournalEntriesMixin',

+ 3 - 3
netbox/extras/models/configs.py

@@ -11,7 +11,7 @@ from extras.querysets import ConfigContextQuerySet
 from netbox.config import get_config
 from netbox.config import get_config
 from netbox.registry import registry
 from netbox.registry import registry
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
-from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
+from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
 from utilities.jinja2 import ConfigTemplateLoader
 from utilities.jinja2 import ConfigTemplateLoader
 from utilities.utils import deepmerge
 from utilities.utils import deepmerge
 
 
@@ -26,7 +26,7 @@ __all__ = (
 # Config contexts
 # Config contexts
 #
 #
 
 
-class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
+class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLoggedModel):
     """
     """
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
     A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
     qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
     qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@@ -210,7 +210,7 @@ class ConfigContextModel(models.Model):
 # Config templates
 # Config templates
 #
 #
 
 
-class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
     name = models.CharField(
     name = models.CharField(
         verbose_name=_('name'),
         verbose_name=_('name'),
         max_length=100
         max_length=100

+ 0 - 14
netbox/ipam/forms/model_forms.py

@@ -373,20 +373,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
                 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
                 'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
             )
             )
 
 
-        # Do not allow assigning a network ID or broadcast address to an interface.
-        if interface and (address := self.cleaned_data.get('address')):
-            if address.ip == address.network:
-                msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(ip=address.ip)
-                if address.version == 4 and address.prefixlen not in (31, 32):
-                    raise ValidationError(msg)
-                if address.version == 6 and address.prefixlen not in (127, 128):
-                    raise ValidationError(msg)
-            if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32):
-                msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
-                    ip=address.ip
-                )
-                raise ValidationError(msg)
-
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         ipaddress = super().save(*args, **kwargs)
         ipaddress = super().save(*args, **kwargs)
 
 

+ 2 - 1
netbox/ipam/graphql/types.py

@@ -1,6 +1,7 @@
 import graphene
 import graphene
 
 
 from ipam import filtersets, models
 from ipam import filtersets, models
+from .mixins import IPAddressesMixin
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 
 
@@ -71,7 +72,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
         filterset_class = filtersets.AggregateFilterSet
         filterset_class = filtersets.AggregateFilterSet
 
 
 
 
-class FHRPGroupType(NetBoxObjectType):
+class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
 
 
     class Meta:
     class Meta:
         model = models.FHRPGroup
         model = models.FHRPGroup

+ 19 - 0
netbox/ipam/models/ip.py

@@ -844,6 +844,25 @@ class IPAddress(PrimaryModel):
                     'address': _("Cannot create IP address with /0 mask.")
                     'address': _("Cannot create IP address with /0 mask.")
                 })
                 })
 
 
+            # Do not allow assigning a network ID or broadcast address to an interface.
+            if self.assigned_object:
+                if self.address.ip == self.address.network:
+                    msg = _("{ip} is a network ID, which may not be assigned to an interface.").format(
+                        ip=self.address.ip
+                    )
+                    if self.address.version == 4 and self.address.prefixlen not in (31, 32):
+                        raise ValidationError(msg)
+                    if self.address.version == 6 and self.address.prefixlen not in (127, 128):
+                        raise ValidationError(msg)
+                if (
+                        self.address.version == 4 and self.address.ip == self.address.broadcast and
+                        self.address.prefixlen not in (31, 32)
+                ):
+                    msg = _("{ip} is a broadcast address, which may not be assigned to an interface.").format(
+                        ip=self.address.ip
+                    )
+                    raise ValidationError(msg)
+
             # Enforce unique IP space (if applicable)
             # Enforce unique IP space (if applicable)
             if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
             if (self.vrf is None and get_config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
                 duplicate_ips = self.get_duplicates()
                 duplicate_ips = self.get_duplicates()

+ 1 - 1
netbox/templates/core/job.html

@@ -56,7 +56,7 @@
             <td>
             <td>
               {{ object.scheduled|annotated_date|placeholder }}
               {{ object.scheduled|annotated_date|placeholder }}
               {% if object.interval %}
               {% if object.interval %}
-                ({% blocktrans with interval=object.interval %}every {{ interval }} seconds{% endblocktrans %})
+                ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %})
               {% endif %}
               {% endif %}
             </td>
             </td>
           </tr>
           </tr>

+ 26 - 19
netbox/utilities/forms/utils.py

@@ -51,36 +51,43 @@ def parse_alphanumeric_range(string):
     '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
     '0-3,a-d' => [0, 1, 2, 3, a, b, c, d]
     """
     """
     values = []
     values = []
-    for dash_range in string.split(','):
+    for value in string.split(','):
+        if '-' not in value:
+            # Item is not a range
+            values.append(value)
+            continue
+
+        # Find the range's beginning & end values
         try:
         try:
-            begin, end = dash_range.split('-')
+            begin, end = value.split('-')
             vals = begin + end
             vals = begin + end
             # Break out of loop if there's an invalid pattern to return an error
             # Break out of loop if there's an invalid pattern to return an error
             if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
             if (not (vals.isdigit() or vals.isalpha())) or (vals.isalpha() and not (vals.isupper() or vals.islower())):
                 return []
                 return []
         except ValueError:
         except ValueError:
-            begin, end = dash_range, dash_range
+            raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
+
+        # Numeric range
         if begin.isdigit() and end.isdigit():
         if begin.isdigit() and end.isdigit():
             if int(begin) >= int(end):
             if int(begin) >= int(end):
-                raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
-
+                raise forms.ValidationError(
+                    _('Invalid range: Ending value ({end}) must be greater than beginning value ({begin}).').format(
+                        begin=begin, end=end
+                    )
+                )
             for n in list(range(int(begin), int(end) + 1)):
             for n in list(range(int(begin), int(end) + 1)):
                 values.append(n)
                 values.append(n)
+
+        # Alphanumeric range
         else:
         else:
-            # Value-based
-            if begin == end:
-                values.append(begin)
-            # Range-based
-            else:
-                # Not a valid range (more than a single character)
-                if not len(begin) == len(end) == 1:
-                    raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
-
-                if ord(begin) >= ord(end):
-                    raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
-
-                for n in list(range(ord(begin), ord(end) + 1)):
-                    values.append(chr(n))
+            # Not a valid range (more than a single character)
+            if not len(begin) == len(end) == 1:
+                raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
+            if ord(begin) >= ord(end):
+                raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=value))
+            for n in list(range(ord(begin), ord(end) + 1)):
+                values.append(chr(n))
+
     return values
     return values
 
 
 
 

+ 10 - 1
netbox/utilities/tests/test_forms.py

@@ -191,7 +191,16 @@ class ExpandAlphanumeric(TestCase):
 
 
         self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
         self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
 
 
-    def test_set(self):
+    def test_set_numeric(self):
+        input = 'r[1,2]a'
+        output = sorted([
+            'r1a',
+            'r2a',
+        ])
+
+        self.assertEqual(sorted(expand_alphanumeric_pattern(input)), output)
+
+    def test_set_alpha(self):
         input = '[r,t]1a'
         input = '[r,t]1a'
         output = sorted([
         output = sorted([
             'r1a',
             'r1a',

+ 2 - 2
netbox/virtualization/graphql/types.py

@@ -1,5 +1,5 @@
 from dcim.graphql.types import ComponentObjectType
 from dcim.graphql.types import ComponentObjectType
-from extras.graphql.mixins import ConfigContextMixin
+from extras.graphql.mixins import ConfigContextMixin, ContactsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
 from virtualization import filtersets, models
 from virtualization import filtersets, models
@@ -38,7 +38,7 @@ class ClusterTypeType(OrganizationalObjectType):
         filterset_class = filtersets.ClusterTypeFilterSet
         filterset_class = filtersets.ClusterTypeFilterSet
 
 
 
 
-class VirtualMachineType(ConfigContextMixin, NetBoxObjectType):
+class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
 
 
     class Meta:
     class Meta:
         model = models.VirtualMachine
         model = models.VirtualMachine

+ 16 - 3
netbox/virtualization/tables/virtualmachines.py

@@ -33,6 +33,15 @@ VMINTERFACE_BUTTONS = """
     </ul>
     </ul>
   </span>
   </span>
 {% endif %}
 {% endif %}
+{% if perms.vpn.add_tunnel and not record.tunnel_termination %}
+  <a href="{% url 'vpn:tunnel_add' %}?termination1_type=virtualization.virtualmachine&termination1_parent={{ record.virtual_machine.pk }}&termination1_termination={{ record.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Create a tunnel" class="btn btn-success btn-sm">
+    <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
+  </a>
+{% elif perms.vpn.delete_tunneltermination and record.tunnel_termination %}
+  <a href="{% url 'vpn:tunneltermination_delete' pk=record.tunnel_termination.pk %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" title="Remove tunnel" class="btn btn-danger btn-sm">
+    <i class="mdi mdi-tunnel-outline" aria-hidden="true"></i>
+  </a>
+{% endif %}
 """
 """
 
 
 
 
@@ -64,6 +73,10 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
     role = columns.ColoredLabelColumn(
     role = columns.ColoredLabelColumn(
         verbose_name=_('Role'),
         verbose_name=_('Role'),
     )
     )
+    platform = tables.Column(
+        linkify=True,
+        verbose_name=_('Platform')
+    )
     comments = columns.MarkdownColumn(
     comments = columns.MarkdownColumn(
         verbose_name=_('Comments'),
         verbose_name=_('Comments'),
     )
     )
@@ -97,9 +110,9 @@ class VirtualMachineTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable)
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = VirtualMachine
         model = VirtualMachine
         fields = (
         fields = (
-            'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform',
-            'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments',
-            'config_template', 'contacts', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'vcpus',
+            'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'description', 'comments', 'config_template',
+            'contacts', 'tags', 'created', 'last_updated',
         )
         )
         default_columns = (
         default_columns = (
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
             'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',

+ 0 - 3
netbox/vpn/admin.py

@@ -1,3 +0,0 @@
-from django.contrib import admin
-
-# Register your models here.

+ 1 - 1
netbox/vpn/choices.py

@@ -124,7 +124,7 @@ class EncryptionAlgorithmChoices(ChoiceSet):
         (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
         (ENCRYPTION_AES256_CBC, '256-bit AES (CBC)'),
         (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
         (ENCRYPTION_AES256_GCM, '256-bit AES (GCM)'),
         (ENCRYPTION_3DES, '3DES'),
         (ENCRYPTION_3DES, '3DES'),
-        (ENCRYPTION_3DES, 'DES'),
+        (ENCRYPTION_DES, 'DES'),
     )
     )
 
 
 
 

+ 6 - 6
requirements.txt

@@ -1,9 +1,9 @@
-Django==5.0.1
+Django==5.0.3
 django-cors-headers==4.3.1
 django-cors-headers==4.3.1
 django-debug-toolbar==4.3.0
 django-debug-toolbar==4.3.0
-django-filter==23.5
+django-filter==24.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
-django-htmx==1.17.2
+django-htmx==1.17.3
 django-mptt==0.14.0
 django-mptt==0.14.0
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.3.1
 django-prometheus==2.3.1
@@ -15,14 +15,14 @@ django-tables2==2.7.0
 django-timezone-field==6.1.0
 django-timezone-field==6.1.0
 djangorestframework==3.14.0
 djangorestframework==3.14.0
 drf-spectacular==0.27.1
 drf-spectacular==0.27.1
-drf-spectacular-sidecar==2024.2.1
+drf-spectacular-sidecar==2024.3.4
 feedparser==6.0.11
 feedparser==6.0.11
 graphene-django==3.0.0
 graphene-django==3.0.0
 gunicorn==21.2.0
 gunicorn==21.2.0
 Jinja2==3.1.3
 Jinja2==3.1.3
 Markdown==3.5.2
 Markdown==3.5.2
-mkdocs-material==9.5.10
-mkdocstrings[python-legacy]==0.24.0
+mkdocs-material==9.5.13
+mkdocstrings[python-legacy]==0.24.1
 netaddr==1.2.1
 netaddr==1.2.1
 nh3==0.2.15
 nh3==0.2.15
 Pillow==10.2.0
 Pillow==10.2.0