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

Merge branch 'feature' into 9856-strawberry-2

Jeremy Stretch 1 год назад
Родитель
Сommit
9c29f45c1a
57 измененных файлов с 223 добавлено и 132 удалено
  1. 3 2
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      .github/workflows/ci.yml
  4. 1 1
      base_requirements.txt
  5. 3 0
      contrib/generated_schema.json
  6. 1 2
      docs/installation/1-postgresql.md
  7. 1 2
      docs/installation/2-redis.md
  8. 1 2
      docs/installation/4-gunicorn.md
  9. 26 1
      docs/release-notes/version-3.7.md
  10. 1 0
      netbox/circuits/graphql/schema.py
  11. 2 3
      netbox/circuits/graphql/types.py
  12. 1 2
      netbox/core/graphql/filters.py
  13. 1 0
      netbox/core/graphql/schema.py
  14. 10 2
      netbox/dcim/api/nested_serializers.py
  15. 2 2
      netbox/dcim/api/serializers_/devicetypes.py
  16. 2 1
      netbox/dcim/api/serializers_/virtualchassis.py
  17. 4 1
      netbox/dcim/api/views.py
  18. 6 0
      netbox/dcim/choices.py
  19. 1 2
      netbox/dcim/graphql/filters.py
  20. 0 4
      netbox/dcim/graphql/gfk_mixins.py
  21. 2 2
      netbox/dcim/graphql/mixins.py
  22. 1 0
      netbox/dcim/graphql/schema.py
  23. 2 7
      netbox/dcim/graphql/types.py
  24. 4 2
      netbox/dcim/models/devices.py
  25. 5 1
      netbox/dcim/tables/devices.py
  26. 1 1
      netbox/dcim/views.py
  27. 1 2
      netbox/extras/graphql/filters.py
  28. 3 2
      netbox/extras/graphql/mixins.py
  29. 1 0
      netbox/extras/graphql/schema.py
  30. 0 4
      netbox/extras/graphql/types.py
  31. 3 3
      netbox/extras/models/configs.py
  32. 0 14
      netbox/ipam/forms/model_forms.py
  33. 1 3
      netbox/ipam/graphql/filters.py
  34. 2 1
      netbox/ipam/graphql/mixins.py
  35. 1 0
      netbox/ipam/graphql/schema.py
  36. 5 9
      netbox/ipam/graphql/types.py
  37. 19 0
      netbox/ipam/models/ip.py
  38. 26 1
      netbox/netbox/search/__init__.py
  39. 5 3
      netbox/netbox/tables/tables.py
  40. 1 1
      netbox/templates/core/job.html
  41. 1 2
      netbox/tenancy/graphql/filters.py
  42. 1 0
      netbox/tenancy/graphql/schema.py
  43. 1 1
      netbox/tenancy/graphql/types.py
  44. 26 19
      netbox/utilities/forms/utils.py
  45. 10 1
      netbox/utilities/tests/test_forms.py
  46. 1 3
      netbox/virtualization/graphql/filters.py
  47. 1 0
      netbox/virtualization/graphql/schema.py
  48. 2 2
      netbox/virtualization/graphql/types.py
  49. 16 3
      netbox/virtualization/tables/virtualmachines.py
  50. 0 3
      netbox/vpn/admin.py
  51. 1 1
      netbox/vpn/choices.py
  52. 1 2
      netbox/vpn/graphql/filters.py
  53. 1 0
      netbox/vpn/graphql/schema.py
  54. 1 2
      netbox/wireless/graphql/filters.py
  55. 1 0
      netbox/wireless/graphql/schema.py
  56. 2 2
      netbox/wireless/graphql/types.py
  57. 6 6
      requirements.txt

+ 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

@@ -96,7 +96,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
 
 
 ---
 ---
 
 

+ 1 - 0
netbox/circuits/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 2 - 3
netbox/circuits/graphql/types.py

@@ -2,13 +2,12 @@ from typing import Annotated, List
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+
 from circuits import models
 from circuits import models
 from dcim.graphql.mixins import CabledObjectMixin
 from dcim.graphql.mixins import CabledObjectMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin
-from tenancy.graphql.types import TenantType
-
 from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType
 from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType
-
+from tenancy.graphql.types import TenantType
 from .filters import *
 from .filters import *
 
 
 __all__ = (
 __all__ = (

+ 1 - 2
netbox/core/graphql/filters.py

@@ -1,7 +1,6 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from core import filtersets, models
 
 
+from core import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 
 __all__ = (
 __all__ = (

+ 1 - 0
netbox/core/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

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

+ 2 - 1
netbox/dcim/api/serializers_/virtualchassis.py

@@ -12,6 +12,7 @@ __all__ = (
 class VirtualChassisSerializer(NetBoxModelSerializer):
 class VirtualChassisSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
     master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
     master = NestedDeviceSerializer(required=False, allow_null=True, default=None)
+    members = NestedDeviceSerializer(many=True, read_only=True)
 
 
     # Counter fields
     # Counter fields
     member_count = serializers.IntegerField(read_only=True)
     member_count = serializers.IntegerField(read_only=True)
@@ -20,6 +21,6 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
         model = VirtualChassis
         model = VirtualChassis
         fields = [
         fields = [
             'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
             'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
-            'created', 'last_updated', 'member_count',
+            'created', 'last_updated', 'member_count', 'members',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
         brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')

+ 4 - 1
netbox/dcim/api/views.py

@@ -511,7 +511,10 @@ class CableTerminationViewSet(NetBoxModelViewSet):
 #
 #
 
 
 class VirtualChassisViewSet(NetBoxModelViewSet):
 class VirtualChassisViewSet(NetBoxModelViewSet):
-    queryset = VirtualChassis.objects.all()
+    queryset = VirtualChassis.objects.prefetch_related(
+        # Prefetch related object for the display of unnamed devices
+        'master__virtual_chassis',
+    )
     serializer_class = serializers.VirtualChassisSerializer
     serializer_class = serializers.VirtualChassisSerializer
     filterset_class = filtersets.VirtualChassisFilterSet
     filterset_class = filtersets.VirtualChassisFilterSet
 
 

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

+ 1 - 2
netbox/dcim/graphql/filters.py

@@ -1,7 +1,6 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from dcim import filtersets, models
 
 
+from dcim import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 
 __all__ = (
 __all__ = (

+ 0 - 4
netbox/dcim/graphql/gfk_mixins.py

@@ -1,7 +1,3 @@
-from typing import TYPE_CHECKING, Annotated, List, Union
-
-import strawberry
-import strawberry_django
 from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
 from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType
 from circuits.models import CircuitTermination, ProviderNetwork
 from circuits.models import CircuitTermination, ProviderNetwork
 from dcim.graphql.types import (
 from dcim.graphql.types import (

+ 2 - 2
netbox/dcim/graphql/mixins.py

@@ -1,7 +1,7 @@
+from typing import Annotated, List, Union
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from typing import TYPE_CHECKING, Annotated, List, Union
-
 
 
 __all__ = (
 __all__ = (
     'CabledObjectMixin',
     'CabledObjectMixin',

+ 1 - 0
netbox/dcim/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 2 - 7
netbox/dcim/graphql/types.py

@@ -2,6 +2,7 @@ from typing import Annotated, List, Union
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+
 from dcim import models
 from dcim import models
 from extras.graphql.mixins import (
 from extras.graphql.mixins import (
     ChangelogMixin,
     ChangelogMixin,
@@ -12,14 +13,8 @@ from extras.graphql.mixins import (
     TagsMixin,
     TagsMixin,
 )
 )
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
 from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
-
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
-from netbox.graphql.types import (
-    BaseObjectType,
-    NetBoxObjectType,
-    OrganizationalObjectType,
-)
-
+from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
 from .filters import *
 from .filters import *
 from .mixins import CabledObjectMixin, PathEndpointMixin
 from .mixins import CabledObjectMixin, PathEndpointMixin
 
 

+ 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 - 2
netbox/extras/graphql/filters.py

@@ -1,7 +1,6 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from extras import filtersets, models
 
 
+from extras import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 
 __all__ = (
 __all__ = (

+ 3 - 2
netbox/extras/graphql/mixins.py

@@ -1,7 +1,7 @@
-import strawberry
-import strawberry_django
 from typing import TYPE_CHECKING, Annotated, List
 from typing import TYPE_CHECKING, Annotated, List
 
 
+import strawberry
+import strawberry_django
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 
 
 from extras.models import ObjectChange
 from extras.models import ObjectChange
@@ -9,6 +9,7 @@ from extras.models import ObjectChange
 __all__ = (
 __all__ = (
     'ChangelogMixin',
     'ChangelogMixin',
     'ConfigContextMixin',
     'ConfigContextMixin',
+    'ContactsMixin',
     'CustomFieldsMixin',
     'CustomFieldsMixin',
     'ImageAttachmentsMixin',
     'ImageAttachmentsMixin',
     'JournalEntriesMixin',
     'JournalEntriesMixin',

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

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 0 - 4
netbox/extras/graphql/types.py

@@ -3,10 +3,6 @@ from typing import Annotated, List
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 
-import strawberry
-from strawberry import auto
-import strawberry_django
-
 from extras import models
 from extras import models
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType
 from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType

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

+ 1 - 3
netbox/ipam/graphql/filters.py

@@ -1,10 +1,8 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from ipam import filtersets, models
 
 
+from ipam import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 
-
 __all__ = (
 __all__ = (
     'ASNFilter',
     'ASNFilter',
     'ASNRangeFilter',
     'ASNRangeFilter',

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

@@ -1,6 +1,7 @@
+from typing import Annotated, List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
-from typing import TYPE_CHECKING, Annotated, List, Union
 
 
 __all__ = (
 __all__ = (
     'IPAddressesMixin',
     'IPAddressesMixin',

+ 1 - 0
netbox/ipam/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 5 - 9
netbox/ipam/graphql/types.py

@@ -1,19 +1,15 @@
-from typing import TYPE_CHECKING, Annotated, List, Union
+from typing import Annotated, List, Union
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
+
 from circuits.graphql.types import ProviderType
 from circuits.graphql.types import ProviderType
 from dcim.graphql.types import SiteType
 from dcim.graphql.types import SiteType
 from ipam import models
 from ipam import models
-
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.scalars import BigInt
-from netbox.graphql.types import (
-    BaseObjectType,
-    NetBoxObjectType,
-    OrganizationalObjectType,
-)
-
+from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType
 from .filters import *
 from .filters import *
+from .mixins import IPAddressesMixin
 
 
 __all__ = (
 __all__ = (
     'ASNType',
     'ASNType',
@@ -101,7 +97,7 @@ class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
     fields='__all__',
     fields='__all__',
     filters=FHRPGroupFilter
     filters=FHRPGroupFilter
 )
 )
-class FHRPGroupType(NetBoxObjectType):
+class FHRPGroupType(NetBoxObjectType, IPAddressesMixin):
 
 
     @strawberry_django.field
     @strawberry_django.field
     def fhrpgroupassignment_set(self) -> List[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]]:
     def fhrpgroupassignment_set(self) -> List[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]]:

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

+ 26 - 1
netbox/netbox/search/__init__.py

@@ -1,6 +1,9 @@
 from collections import namedtuple
 from collections import namedtuple
+from decimal import Decimal
 
 
+from django.core.exceptions import FieldDoesNotExist
 from django.db import models
 from django.db import models
+from netaddr import IPAddress, IPNetwork
 
 
 from ipam.fields import IPAddressField, IPNetworkField
 from ipam.fields import IPAddressField, IPNetworkField
 from netbox.registry import registry
 from netbox.registry import registry
@@ -56,6 +59,24 @@ class SearchIndex:
             return FieldTypes.INTEGER
             return FieldTypes.INTEGER
         return FieldTypes.STRING
         return FieldTypes.STRING
 
 
+    @staticmethod
+    def get_attr_type(instance, field_name):
+        """
+        Return the data type of the specified object attribute.
+        """
+        value = getattr(instance, field_name)
+        if type(value) is str:
+            return FieldTypes.STRING
+        if type(value) is int:
+            return FieldTypes.INTEGER
+        if type(value) in (float, Decimal):
+            return FieldTypes.FLOAT
+        if type(value) is IPNetwork:
+            return FieldTypes.CIDR
+        if type(value) is IPAddress:
+            return FieldTypes.INET
+        return FieldTypes.STRING
+
     @staticmethod
     @staticmethod
     def get_field_value(instance, field_name):
     def get_field_value(instance, field_name):
         """
         """
@@ -82,7 +103,11 @@ class SearchIndex:
 
 
         # Capture built-in fields
         # Capture built-in fields
         for name, weight in cls.fields:
         for name, weight in cls.fields:
-            type_ = cls.get_field_type(instance, name)
+            try:
+                type_ = cls.get_field_type(instance, name)
+            except FieldDoesNotExist:
+                # Not a concrete field; handle as an object attribute
+                type_ = cls.get_attr_type(instance, name)
             value = cls.get_field_value(instance, name)
             value = cls.get_field_value(instance, name)
             if type_ and value:
             if type_ and value:
                 values.append(
                 values.append(

+ 5 - 3
netbox/netbox/tables/tables.py

@@ -263,9 +263,11 @@ class SearchTable(tables.Table):
         super().__init__(data, **kwargs)
         super().__init__(data, **kwargs)
 
 
     def render_field(self, value, record):
     def render_field(self, value, record):
-        if hasattr(record.object, value):
-            return title(record.object._meta.get_field(value).verbose_name)
-        return value
+        try:
+            model_field = record.object._meta.get_field(value)
+            return title(model_field.verbose_name)
+        except FieldDoesNotExist:
+            return value
 
 
     def render_value(self, value):
     def render_value(self, value):
         if not self.highlight:
         if not self.highlight:

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

+ 1 - 2
netbox/tenancy/graphql/filters.py

@@ -1,8 +1,7 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from tenancy import filtersets, models
 
 
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+from tenancy import filtersets, models
 
 
 __all__ = (
 __all__ = (
     'TenantFilter',
     'TenantFilter',

+ 1 - 0
netbox/tenancy/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 1 - 1
netbox/tenancy/graphql/types.py

@@ -4,8 +4,8 @@ import strawberry
 import strawberry_django
 import strawberry_django
 
 
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
 from extras.graphql.mixins import CustomFieldsMixin, TagsMixin
-from tenancy import models
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
+from tenancy import models
 from .filters import *
 from .filters import *
 
 
 __all__ = (
 __all__ = (

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

+ 1 - 3
netbox/virtualization/graphql/filters.py

@@ -1,9 +1,7 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from virtualization import filtersets, models
 
 
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
-
+from virtualization import filtersets, models
 
 
 __all__ = (
 __all__ = (
     'ClusterFilter',
     'ClusterFilter',

+ 1 - 0
netbox/virtualization/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

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

@@ -3,7 +3,7 @@ from typing import Annotated, List
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 
-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.scalars import BigInt
 from netbox.graphql.scalars import BigInt
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
@@ -78,7 +78,7 @@ class ClusterTypeType(OrganizationalObjectType):
     fields='__all__',
     fields='__all__',
     filters=VirtualMachineFilter
     filters=VirtualMachineFilter
 )
 )
-class VirtualMachineType(ConfigContextMixin, NetBoxObjectType):
+class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
     _name: str
     _name: str
     interface_count: BigInt
     interface_count: BigInt
     virtual_disk_count: BigInt
     virtual_disk_count: BigInt

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

+ 1 - 2
netbox/vpn/graphql/filters.py

@@ -1,8 +1,7 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from vpn import filtersets, models
 
 
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+from vpn import filtersets, models
 
 
 __all__ = (
 __all__ = (
     'TunnelGroupFilter',
     'TunnelGroupFilter',

+ 1 - 0
netbox/vpn/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

+ 1 - 2
netbox/wireless/graphql/filters.py

@@ -1,8 +1,7 @@
-import strawberry
 import strawberry_django
 import strawberry_django
-from wireless import filtersets, models
 
 
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
+from wireless import filtersets, models
 
 
 __all__ = (
 __all__ = (
     'WirelessLANGroupFilter',
     'WirelessLANGroupFilter',

+ 1 - 0
netbox/wireless/graphql/schema.py

@@ -1,4 +1,5 @@
 from typing import List
 from typing import List
+
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 

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

@@ -1,10 +1,10 @@
-from typing import Annotated, List, Union
+from typing import Annotated, List
 
 
 import strawberry
 import strawberry
 import strawberry_django
 import strawberry_django
 
 
-from wireless import models
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
 from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType
+from wireless import models
 from .filters import *
 from .filters import *
 
 
 __all__ = (
 __all__ = (

+ 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,13 +15,13 @@ 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
 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