ソースを参照

Merge pull request #15222 from netbox-community/develop

Release v3.7.3
Jeremy Stretch 2 年 前
コミット
b7f6b728b9
99 ファイル変更2415 行追加1744 行削除
  1. 4 2
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 1 1
      README.md
  4. 1 1
      base_requirements.txt
  5. 1 1
      docs/configuration/remote-authentication.md
  6. 1 1
      docs/customization/custom-scripts.md
  7. 35 0
      docs/release-notes/version-3.7.md
  8. 2 2
      netbox/circuits/models/circuits.py
  9. 1 0
      netbox/core/api/schema.py
  10. 1 1
      netbox/core/data_backends.py
  11. 2 2
      netbox/core/forms/model_forms.py
  12. 1 1
      netbox/core/models/config.py
  13. 2 2
      netbox/core/models/data.py
  14. 5 1
      netbox/core/models/jobs.py
  15. 1 1
      netbox/core/views.py
  16. 2 0
      netbox/dcim/api/serializers.py
  17. 6 0
      netbox/dcim/api/views.py
  18. 3 2
      netbox/dcim/fields.py
  19. 14 0
      netbox/dcim/filtersets.py
  20. 29 6
      netbox/dcim/forms/bulk_import.py
  21. 9 2
      netbox/dcim/forms/model_forms.py
  22. 18 10
      netbox/dcim/models/cables.py
  23. 2 2
      netbox/dcim/models/device_components.py
  24. 1 1
      netbox/dcim/models/devices.py
  25. 5 0
      netbox/dcim/tables/devices.py
  26. 1 1
      netbox/dcim/tables/template_code.py
  27. 2 1
      netbox/dcim/tests/test_api.py
  28. 12 0
      netbox/dcim/tests/test_filtersets.py
  29. 2 1
      netbox/extras/api/customfields.py
  30. 6 5
      netbox/extras/api/serializers.py
  31. 12 6
      netbox/extras/conditions.py
  32. 2 1
      netbox/extras/dashboard/utils.py
  33. 5 3
      netbox/extras/dashboard/widgets.py
  34. 5 2
      netbox/extras/events.py
  35. 2 2
      netbox/extras/forms/bulk_import.py
  36. 2 1
      netbox/extras/management/commands/reindex.py
  37. 17 0
      netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py
  38. 3 0
      netbox/extras/models/search.py
  39. 4 3
      netbox/extras/scripts.py
  40. 38 34
      netbox/extras/signals.py
  41. 20 0
      netbox/extras/validators.py
  42. 2 1
      netbox/ipam/api/nested_serializers.py
  43. 2 1
      netbox/ipam/api/views.py
  44. 2 1
      netbox/ipam/fields.py
  45. 8 7
      netbox/ipam/formfields.py
  46. 1 1
      netbox/ipam/forms/model_forms.py
  47. 1 1
      netbox/ipam/tests/test_api.py
  48. 9 4
      netbox/ipam/validators.py
  49. 6 5
      netbox/netbox/api/fields.py
  50. 11 5
      netbox/netbox/api/serializers/nested.py
  51. 5 1
      netbox/netbox/authentication.py
  52. 2 1
      netbox/netbox/config/__init__.py
  53. 3 1
      netbox/netbox/forms/mixins.py
  54. 13 7
      netbox/netbox/models/features.py
  55. 5 4
      netbox/netbox/plugins/navigation.py
  56. 25 6
      netbox/netbox/plugins/registration.py
  57. 2 1
      netbox/netbox/plugins/templates.py
  58. 4 3
      netbox/netbox/registry.py
  59. 1 2
      netbox/netbox/settings.py
  60. 10 6
      netbox/netbox/views/generic/bulk_views.py
  61. 4 1
      netbox/netbox/views/generic/object_views.py
  62. 4 0
      netbox/templates/account/profile.html
  63. 1 1
      netbox/templates/dcim/devicetype.html
  64. 4 0
      netbox/templates/users/user.html
  65. 2 2
      netbox/templates/vpn/l2vpntermination_edit.html
  66. 256 211
      netbox/translations/en/LC_MESSAGES/django.po
  67. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  68. 273 217
      netbox/translations/es/LC_MESSAGES/django.po
  69. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  70. 276 217
      netbox/translations/fr/LC_MESSAGES/django.po
  71. BIN
      netbox/translations/ja/LC_MESSAGES/django.mo
  72. 276 231
      netbox/translations/ja/LC_MESSAGES/django.po
  73. BIN
      netbox/translations/pt/LC_MESSAGES/django.mo
  74. 274 217
      netbox/translations/pt/LC_MESSAGES/django.po
  75. BIN
      netbox/translations/ru/LC_MESSAGES/django.mo
  76. 276 222
      netbox/translations/ru/LC_MESSAGES/django.po
  77. BIN
      netbox/translations/tr/LC_MESSAGES/django.mo
  78. 267 216
      netbox/translations/tr/LC_MESSAGES/django.po
  79. 1 1
      netbox/users/api/serializers.py
  80. 7 1
      netbox/users/forms/model_forms.py
  81. 1 1
      netbox/utilities/fields.py
  82. 2 2
      netbox/utilities/forms/bulk_import.py
  83. 2 0
      netbox/utilities/forms/fields/fields.py
  84. 22 11
      netbox/utilities/forms/utils.py
  85. 11 2
      netbox/utilities/forms/widgets/apiselect.py
  86. 3 2
      netbox/utilities/permissions.py
  87. 2 1
      netbox/utilities/request.py
  88. 4 1
      netbox/utilities/tables.py
  89. 1 1
      netbox/utilities/templates/buttons/export.html
  90. 2 1
      netbox/utilities/testing/views.py
  91. 17 8
      netbox/utilities/utils.py
  92. 2 1
      netbox/utilities/validators.py
  93. 11 4
      netbox/utilities/views.py
  94. 2 2
      netbox/virtualization/api/serializers.py
  95. 4 1
      netbox/vpn/api/serializers.py
  96. 7 3
      netbox/vpn/tables/tunnels.py
  97. 0 1
      netbox/vpn/tests/test_api.py
  98. 3 2
      netbox/wireless/utils.py
  99. 5 5
      requirements.txt

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

@@ -13,7 +13,9 @@ body:
   - type: dropdown
     attributes:
       label: Deployment Type
-      description: How are you running NetBox?
+      description: >
+        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.)
       options:
         - Self-hosted
         - NetBox Cloud
@@ -23,7 +25,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v3.7.2
+      placeholder: v3.7.3
     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.7.2
+      placeholder: v3.7.3
     validations:
       required: true
   - type: dropdown

+ 1 - 1
README.md

@@ -5,7 +5,7 @@
   <a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
   <a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
   <a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
-  <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-6-blue" alt="Languages supported" /></a>
+  <a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-7-blue" alt="Languages supported" /></a>
   <a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master" alt="CI status" /></a>
   <p></p>
 </div>

+ 1 - 1
base_requirements.txt

@@ -105,7 +105,7 @@ mkdocs-material
 mkdocstrings[python-legacy]
 
 # Library for manipulating IP prefixes and addresses
-# https://github.com/netaddr/netaddr/blob/master/CHANGELOG
+# https://github.com/netaddr/netaddr/blob/master/CHANGELOG.rst
 netaddr
 
 # Fork of PIL (Python Imaging Library) for image processing

+ 1 - 1
docs/configuration/remote-authentication.md

@@ -67,7 +67,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
 
 Default: `|` (Pipe)
 
-The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
+The Separator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` )
 
 ---
 

+ 1 - 1
docs/customization/custom-scripts.md

@@ -390,7 +390,7 @@ class NewBranchScript(Script):
                 name=f'{site.slug}-switch{i}',
                 site=site,
                 status=DeviceStatusChoices.STATUS_PLANNED,
-                role=switch_role
+                device_role=switch_role
             )
             switch.full_clean()
             switch.save()

+ 35 - 0
docs/release-notes/version-3.7.md

@@ -1,5 +1,40 @@
 # NetBox v3.7
 
+## v3.7.3 (2024-02-21)
+
+### Enhancements
+
+* [#14587](https://github.com/netbox-community/netbox/issues/14587) - Display a human-friendly name for the OpenID Connect remote auth backend
+* [#14946](https://github.com/netbox-community/netbox/issues/14946) - Remove `associate_by_email()` from default social auth pipeline
+* [#14966](https://github.com/netbox-community/netbox/issues/14966) - Add PostgreSQL index for object type & ID on CachedValue table to improve performance
+* [#15177](https://github.com/netbox-community/netbox/issues/15177) - Add "last login" time to user display & REST API serializer
+
+### Bug Fixes
+
+* [#14058](https://github.com/netbox-community/netbox/issues/14058) - Limit platform options by manufacturer when editing a device or device type
+* [#14064](https://github.com/netbox-community/netbox/issues/14064) - Resolving parent location should consider assigned site when bulk importing locations
+* [#14079](https://github.com/netbox-community/netbox/issues/14079) - Ensure changes are logged on related objects when deleting an object referenced via a many-to-many relationship (e.g. tags)
+* [#14405](https://github.com/netbox-community/netbox/issues/14405) - Clean up formatting of link peers in bulk CSV export of cable termination objects
+* [#14689](https://github.com/netbox-community/netbox/issues/14689) - Preserve "empty" default values for JSON custom fields
+* [#14952](https://github.com/netbox-community/netbox/issues/14952) - Update existing AutoSyncRecord when changing the data file of an auto-synced object
+* [#15059](https://github.com/netbox-community/netbox/issues/15059) - Correct IP address count link in VM interfaces table
+* [#15067](https://github.com/netbox-community/netbox/issues/15067) - Fix uncaught exception when attempting invalid device bay import
+* [#15070](https://github.com/netbox-community/netbox/issues/15070) - Fix inclusion of `config_template` field on REST API serializer for virtual machines
+* [#15084](https://github.com/netbox-community/netbox/issues/15084) - Fix "add export template" link under "export" button on object list views
+* [#15090](https://github.com/netbox-community/netbox/issues/15090) - Ensure protection rules are evaluated prior to enqueueing events when deleting an object
+* [#15091](https://github.com/netbox-community/netbox/issues/15091) - Fix designation of the active tab for assigned object when modifying an L2VPN termination
+* [#15101](https://github.com/netbox-community/netbox/issues/15101) - Correct OpenAPI schema for rack elevation REST API endpoint
+* [#15115](https://github.com/netbox-community/netbox/issues/15115) - Fix unhandled exception with invalid permission constraints
+* [#15126](https://github.com/netbox-community/netbox/issues/15126) - `group` field should be optional when creating VPN tunnel via REST API
+* [#15127](https://github.com/netbox-community/netbox/issues/15127) - Add missing group column to VPN tunnels table
+* [#15133](https://github.com/netbox-community/netbox/issues/15133) - Fix FHRP group representation on assignments REST API endpoint using brief mode
+* [#15174](https://github.com/netbox-community/netbox/issues/15174) - Warn that permission constraints are not supported for reports or scripts
+* [#15184](https://github.com/netbox-community/netbox/issues/15184) - Correct REST API schema definition for `front_image` & `rear_image` on DeviceType
+* [#15185](https://github.com/netbox-community/netbox/issues/15185) - Ensure error messages pertaining to related objects are displayed on the bulk import form
+* [#15192](https://github.com/netbox-community/netbox/issues/15192) - Fix exception when viewing current config when no history is present
+
+---
+
 ## v3.7.2 (2024-02-05)
 
 ### Enhancements

+ 2 - 2
netbox/circuits/models/circuits.py

@@ -234,9 +234,9 @@ class CircuitTermination(
 
         # Must define either site *or* provider network
         if self.site is None and self.provider_network is None:
-            raise ValidationError("A circuit termination must attach to either a site or a provider network.")
+            raise ValidationError(_("A circuit termination must attach to either a site or a provider network."))
         if self.site and self.provider_network:
-            raise ValidationError("A circuit termination cannot attach to both a site and a provider network.")
+            raise ValidationError(_("A circuit termination cannot attach to both a site and a provider network."))
 
     def to_objectchange(self, action):
         objectchange = super().to_objectchange(action)

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

@@ -8,6 +8,7 @@ from drf_spectacular.plumbing import (
     build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
 )
 from drf_spectacular.types import OpenApiTypes
+from rest_framework import serializers
 from rest_framework.relations import ManyRelatedField
 
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField

+ 1 - 1
netbox/core/data_backends.py

@@ -102,7 +102,7 @@ class GitBackend(DataBackend):
         try:
             porcelain.clone(self.url, local_path.name, **clone_args)
         except BaseException as e:
-            raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
+            raise SyncError(_("Fetching remote data failed ({name}): {error}").format(name=type(e).__name__, error=e))
 
         yield local_path.name
 

+ 2 - 2
netbox/core/forms/model_forms.py

@@ -103,9 +103,9 @@ class ManagedFileForm(SyncedDataMixin, NetBoxModelForm):
         super().clean()
 
         if self.cleaned_data.get('upload_file') and self.cleaned_data.get('data_file'):
-            raise forms.ValidationError("Cannot upload a file and sync from an existing file")
+            raise forms.ValidationError(_("Cannot upload a file and sync from an existing file"))
         if not self.cleaned_data.get('upload_file') and not self.cleaned_data.get('data_file'):
-            raise forms.ValidationError("Must upload a file or select a data file to sync")
+            raise forms.ValidationError(_("Must upload a file or select a data file to sync"))
 
         return self.cleaned_data
 

+ 1 - 1
netbox/core/models/config.py

@@ -44,7 +44,7 @@ class ConfigRevision(models.Model):
         return gettext('Config revision #{id}').format(id=self.pk)
 
     def __getattr__(self, item):
-        if item in self.data:
+        if self.data and item in self.data:
             return self.data[item]
         return super().__getattribute__(item)
 

+ 2 - 2
netbox/core/models/data.py

@@ -177,7 +177,7 @@ class DataSource(JobsMixin, PrimaryModel):
         Create/update/delete child DataFiles as necessary to synchronize with the remote source.
         """
         if self.status == DataSourceStatusChoices.SYNCING:
-            raise SyncError("Cannot initiate sync; syncing already in progress.")
+            raise SyncError(_("Cannot initiate sync; syncing already in progress."))
 
         # Emit the pre_sync signal
         pre_sync.send(sender=self.__class__, instance=self)
@@ -190,7 +190,7 @@ class DataSource(JobsMixin, PrimaryModel):
             backend = self.get_backend()
         except ModuleNotFoundError as e:
             raise SyncError(
-                f"There was an error initializing the backend. A dependency needs to be installed: {e}"
+                _("There was an error initializing the backend. A dependency needs to be installed: ") + str(e)
             )
         with backend.fetch() as local_path:
 

+ 5 - 1
netbox/core/models/jobs.py

@@ -181,7 +181,11 @@ class Job(models.Model):
         """
         valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
         if status not in valid_statuses:
-            raise ValueError(f"Invalid status for job termination. Choices are: {', '.join(valid_statuses)}")
+            raise ValueError(
+                _("Invalid status for job termination. Choices are: {choices}").format(
+                    choices=', '.join(valid_statuses)
+                )
+            )
 
         # Mark the job as completed
         self.status = status

+ 1 - 1
netbox/core/views.py

@@ -166,7 +166,7 @@ class ConfigView(generic.ObjectView):
         except ConfigRevision.DoesNotExist:
             # Fall back to using the active config data if no record is found
             return ConfigRevision(
-                data=get_config()
+                data=get_config().defaults
             )
 
 

+ 2 - 0
netbox/dcim/api/serializers.py

@@ -326,6 +326,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     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)
     device_count = serializers.IntegerField(read_only=True)
+    front_image = serializers.URLField(allow_null=True, required=False)
+    rear_image = serializers.URLField(allow_null=True, required=False)
 
     # Counter fields
     console_port_template_count = serializers.IntegerField(read_only=True)

+ 6 - 0
netbox/dcim/api/views.py

@@ -191,6 +191,12 @@ class RackViewSet(NetBoxModelViewSet):
     serializer_class = serializers.RackSerializer
     filterset_class = filtersets.RackFilterSet
 
+    @extend_schema(
+        operation_id='dcim_racks_elevation_retrieve',
+        filters=False,
+        parameters=[serializers.RackElevationDetailFilterSerializer],
+        responses={200: serializers.RackUnitSerializer(many=True)}
+    )
     @action(detail=True)
     def elevation(self, request, pk=None):
         """

+ 3 - 2
netbox/dcim/fields.py

@@ -1,6 +1,7 @@
 from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.db import models
+from django.utils.translation import gettext as _
 from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
 
 from .lookups import PathContains
@@ -41,7 +42,7 @@ class MACAddressField(models.Field):
         try:
             return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
         except AddrFormatError:
-            raise ValidationError(f"Invalid MAC address format: {value}")
+            raise ValidationError(_("Invalid MAC address format: {value}").format(value=value))
 
     def db_type(self, connection):
         return 'macaddr'
@@ -67,7 +68,7 @@ class WWNField(models.Field):
         try:
             return EUI(value, version=64, dialect=eui64_unix_expanded_uppercase)
         except AddrFormatError:
-            raise ValidationError(f"Invalid WWN format: {value}")
+            raise ValidationError(_("Invalid WWN format: {value}").format(value=value))
 
     def db_type(self, connection):
         return 'macaddr8'

+ 14 - 0
netbox/dcim/filtersets.py

@@ -2,6 +2,8 @@ import django_filters
 from django.contrib.auth import get_user_model
 from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext as _
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema_field
 
 from circuits.models import CircuitTermination
 from extras.filtersets import LocalConfigContextFilterSet
@@ -818,6 +820,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         to_field_name='slug',
         label=_('Manufacturer (slug)'),
     )
+    available_for_device_type = django_filters.ModelChoiceFilter(
+        queryset=DeviceType.objects.all(),
+        method='get_for_device_type'
+    )
     config_template_id = django_filters.ModelMultipleChoiceFilter(
         queryset=ConfigTemplate.objects.all(),
         label=_('Config template (ID)'),
@@ -827,6 +833,14 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
         model = Platform
         fields = ['id', 'name', 'slug', 'description']
 
+    @extend_schema_field(OpenApiTypes.STR)
+    def get_for_device_type(self, queryset, name, value):
+        """
+        Return all Platforms available for a specific manufacturer based on device type and Platforms not assigned any
+        manufacturer
+        """
+        return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value))
+
 
 class DeviceFilterSet(
     NetBoxModelFilterSet,

+ 29 - 6
netbox/dcim/forms/bulk_import.py

@@ -159,6 +159,14 @@ class LocationImportForm(NetBoxModelImportForm):
         model = Location
         fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description', 'tags')
 
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit location queryset by assigned site
+            params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
+            self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
+
 
 class RackRoleImportForm(NetBoxModelImportForm):
     slug = SlugField()
@@ -870,7 +878,11 @@ class InterfaceImportForm(NetBoxModelImportForm):
     def clean_vdcs(self):
         for vdc in self.cleaned_data['vdcs']:
             if vdc.device != self.cleaned_data['device']:
-                raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
+                raise forms.ValidationError(
+                    _("VDC {vdc} is not assigned to device {device}").format(
+                        vdc=vdc, device=self.cleaned_data['device']
+                    )
+                )
         return self.cleaned_data['vdcs']
 
 
@@ -996,7 +1008,7 @@ class DeviceBayImportForm(NetBoxModelImportForm):
                 device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
             ).exclude(pk=device.pk)
         else:
-            self.fields['installed_device'].queryset = Interface.objects.none()
+            self.fields['installed_device'].queryset = Device.objects.none()
 
 
 class InventoryItemImportForm(NetBoxModelImportForm):
@@ -1075,7 +1087,11 @@ class InventoryItemImportForm(NetBoxModelImportForm):
             component = model.objects.get(device=device, name=component_name)
             self.instance.component = component
         except ObjectDoesNotExist:
-            raise forms.ValidationError(f"Component not found: {device} - {component_name}")
+            raise forms.ValidationError(
+                _("Component not found: {device} - {component_name}").format(
+                    device=device, component_name=component_name
+                )
+            )
 
 
 #
@@ -1193,10 +1209,17 @@ class CableImportForm(NetBoxModelImportForm):
             else:
                 termination_object = model.objects.get(device=device, name=name)
             if termination_object.cable is not None and termination_object.cable != self.instance:
-                raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected")
+                raise forms.ValidationError(
+                    _("Side {side_upper}: {device} {termination_object} is already connected").format(
+                        side_upper=side.upper(), device=device, termination_object=termination_object
+                    )
+                )
         except ObjectDoesNotExist:
-            raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}")
-
+            raise forms.ValidationError(
+                _("{side_upper} side termination not found: {device} {name}").format(
+                    side_upper=side.upper(), device=device, name=name
+                )
+            )
         setattr(self.instance, f'{side}_terminations', [termination_object])
         return termination_object
 

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

@@ -291,7 +291,11 @@ class DeviceTypeForm(NetBoxModelForm):
     default_platform = DynamicModelChoiceField(
         label=_('Default platform'),
         queryset=Platform.objects.all(),
-        required=False
+        required=False,
+        selector=True,
+        query_params={
+            'manufacturer_id': ['$manufacturer', 'null'],
+        }
     )
     slug = SlugField(
         label=_('Slug'),
@@ -444,7 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
         label=_('Platform'),
         queryset=Platform.objects.all(),
         required=False,
-        selector=True
+        selector=True,
+        query_params={
+            'available_for_device_type': '$device_type',
+        }
     )
     cluster = DynamicModelChoiceField(
         label=_('Cluster'),

+ 18 - 10
netbox/dcim/models/cables.py

@@ -160,25 +160,26 @@ class Cable(PrimaryModel):
 
         # Validate length and length_unit
         if self.length is not None and not self.length_unit:
-            raise ValidationError("Must specify a unit when setting a cable length")
+            raise ValidationError(_("Must specify a unit when setting a cable length"))
 
         if self.pk is None and (not self.a_terminations or not self.b_terminations):
-            raise ValidationError("Must define A and B terminations when creating a new cable.")
+            raise ValidationError(_("Must define A and B terminations when creating a new cable."))
 
         if self._terminations_modified:
 
             # Check that all termination objects for either end are of the same type
             for terms in (self.a_terminations, self.b_terminations):
                 if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]):
-                    raise ValidationError("Cannot connect different termination types to same end of cable.")
+                    raise ValidationError(_("Cannot connect different termination types to same end of cable."))
 
             # Check that termination types are compatible
             if self.a_terminations and self.b_terminations:
                 a_type = self.a_terminations[0]._meta.model_name
                 b_type = self.b_terminations[0]._meta.model_name
                 if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type):
-                    raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}")
-
+                    raise ValidationError(
+                        _("Incompatible termination types: {type_a} and {type_b}").format(type_a=a_type, type_b=b_type)
+                    )
                 if a_type == b_type:
                     # can't directly use self.a_terminations here as possible they
                     # don't have pk yet
@@ -323,17 +324,24 @@ class CableTermination(ChangeLoggedModel):
         ).first()
         if existing_termination is not None:
             raise ValidationError(
-                f"Duplicate termination found for {self.termination_type.app_label}.{self.termination_type.model} "
-                f"{self.termination_id}: cable {existing_termination.cable.pk}"
+                _("Duplicate termination found for {app_label}.{model} {termination_id}: cable {cable_pk}".format(
+                    app_label=self.termination_type.app_label,
+                    model=self.termination_type.model,
+                    termination_id=self.termination_id,
+                    cable_pk=existing_termination.cable.pk
+                ))
             )
-
         # Validate interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces")
+            raise ValidationError(
+                _("Cables cannot be terminated to {type_display} interfaces").format(
+                    type_display=self.termination.get_type_display()
+                )
+            )
 
         # A CircuitTermination attached to a ProviderNetwork cannot have a Cable
         if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None:
-            raise ValidationError("Circuit terminations attached to a provider network may not be cabled.")
+            raise ValidationError(_("Circuit terminations attached to a provider network may not be cabled."))
 
     def save(self, *args, **kwargs):
 

+ 2 - 2
netbox/dcim/models/device_components.py

@@ -1133,13 +1133,13 @@ class DeviceBay(ComponentModel, TrackingModelMixin):
         super().clean()
 
         # Validate that the parent Device can have DeviceBays
-        if not self.device.device_type.is_parent_device:
+        if hasattr(self, 'device') and not self.device.device_type.is_parent_device:
             raise ValidationError(_("This type of device ({device_type}) does not support device bays.").format(
                 device_type=self.device.device_type
             ))
 
         # Cannot install a device into itself, obviously
-        if self.device == self.installed_device:
+        if self.installed_device and getattr(self, 'device', None) == self.installed_device:
             raise ValidationError(_("Cannot install a device into itself."))
 
         # Check that the installed device is not already installed elsewhere

+ 1 - 1
netbox/dcim/models/devices.py

@@ -875,7 +875,7 @@ class Device(
             if self.position and self.device_type.u_height == 0:
                 raise ValidationError({
                     'position': _(
-                        "A U0 device type ({device_type}) cannot be assigned to a rack position."
+                        "A 0U device type ({device_type}) cannot be assigned to a rack position."
                     ).format(device_type=self.device_type)
                 })
 

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

@@ -359,6 +359,11 @@ class CableTerminationTable(NetBoxTable):
         verbose_name=_('Mark Connected'),
     )
 
+    def value_link_peer(self, value):
+        return ', '.join([
+            f"{termination.parent_object} > {termination}" for termination in value
+        ])
+
 
 class PathEndpointTable(CableTerminationTable):
     connection = columns.TemplateColumn(

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

@@ -37,7 +37,7 @@ DEVICEBAY_STATUS = """
 INTERFACE_IPADDRESSES = """
 <div class="table-badge-group">
   {% if value.count >= 3 %}
-    <a href="{% url 'ipam:ipaddress_list' %}?interface_id={{ record.pk }}">{{ value.count }}</a>
+    <a href="{% url 'ipam:ipaddress_list' %}?{{ record|meta:"model_name" }}_id={{ record.pk }}">{{ value.count }}</a>
   {% else %}
     {% for ip in value.all %}
       {% if ip.status != 'active' %}

+ 2 - 1
netbox/dcim/tests/test_api.py

@@ -1,6 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.test import override_settings
 from django.urls import reverse
+from django.utils.translation import gettext as _
 from rest_framework import status
 
 from dcim.choices import *
@@ -45,7 +46,7 @@ class Mixins:
                 name='Peer Device'
             )
             if self.peer_termination_type is None:
-                raise NotImplementedError("Test case must set peer_termination_type")
+                raise NotImplementedError(_("Test case must set peer_termination_type"))
             peer_obj = self.peer_termination_type.objects.create(
                 device=peer_device,
                 name='Peer Termination'

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

@@ -1787,6 +1787,7 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
             Platform(name='Platform 1', slug='platform-1', manufacturer=manufacturers[0], description='foobar1'),
             Platform(name='Platform 2', slug='platform-2', manufacturer=manufacturers[1], description='foobar2'),
             Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturers[2], description='foobar3'),
+            Platform(name='Platform 4', slug='platform-4'),
         )
         Platform.objects.bulk_create(platforms)
 
@@ -1813,6 +1814,17 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_available_for_device_type(self):
+        manufacturers = Manufacturer.objects.all()[:2]
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturers[0],
+            model='Device Type 1',
+            slug='device-type-1',
+            u_height=1
+        )
+        params = {'available_for_device_type': device_type.pk}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Device.objects.all()

+ 2 - 1
netbox/extras/api/customfields.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext as _
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from rest_framework.fields import Field
@@ -88,7 +89,7 @@ class CustomFieldsDataField(Field):
                 if serializer.is_valid():
                     data[cf.name] = [obj['id'] for obj in serializer.data] if many else serializer.data['id']
                 else:
-                    raise ValidationError(f"Unknown related object(s): {data[cf.name]}")
+                    raise ValidationError(_("Unknown related object(s): {name}").format(name=data[cf.name]))
 
         # If updating an existing instance, start with existing custom_field_data
         if self.parent.instance:

+ 6 - 5
netbox/extras/api/serializers.py

@@ -1,5 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
@@ -150,7 +151,7 @@ class CustomFieldSerializer(ValidatedModelSerializer):
 
     def validate_type(self, value):
         if self.instance and self.instance.type != value:
-            raise serializers.ValidationError('Changing the type of custom fields is not supported.')
+            raise serializers.ValidationError(_('Changing the type of custom fields is not supported.'))
 
         return value
 
@@ -545,12 +546,12 @@ class ReportInputSerializer(serializers.Serializer):
 
     def validate_schedule_at(self, value):
         if value and not self.context['report'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this report.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
         return value
 
     def validate_interval(self, value):
         if value and not self.context['report'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this report.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this report."))
         return value
 
 
@@ -595,12 +596,12 @@ class ScriptInputSerializer(serializers.Serializer):
 
     def validate_schedule_at(self, value):
         if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this script.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
         return value
 
     def validate_interval(self, value):
         if value and not self.context['script'].scheduling_enabled:
-            raise serializers.ValidationError("Scheduling is not enabled for this script.")
+            raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
         return value
 
 

+ 12 - 6
netbox/extras/conditions.py

@@ -1,5 +1,6 @@
 import functools
 import re
+from django.utils.translation import gettext as _
 
 __all__ = (
     'Condition',
@@ -50,11 +51,13 @@ class Condition:
 
     def __init__(self, attr, value, op=EQ, negate=False):
         if op not in self.OPERATORS:
-            raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}")
+            raise ValueError(_("Unknown operator: {op}. Must be one of: {operators}").format(
+                op=op, operators=', '.join(self.OPERATORS)
+            ))
         if type(value) not in self.TYPES:
-            raise ValueError(f"Unsupported value type: {type(value)}")
+            raise ValueError(_("Unsupported value type: {value}").format(value=type(value)))
         if op not in self.TYPES[type(value)]:
-            raise ValueError(f"Invalid type for {op} operation: {type(value)}")
+            raise ValueError(_("Invalid type for {op} operation: {value}").format(op=op, value=type(value)))
 
         self.attr = attr
         self.value = value
@@ -131,14 +134,17 @@ class ConditionSet:
     """
     def __init__(self, ruleset):
         if type(ruleset) is not dict:
-            raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.")
+            raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
         if len(ruleset) != 1:
-            raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})")
+            raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
+                ruleset=len(ruleset)))
 
         # Determine the logic type
         logic = list(ruleset.keys())[0]
         if type(logic) is not str or logic.lower() not in (AND, OR):
-            raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')")
+            raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
+                logic=logic, op_and=AND, op_or=OR
+            ))
         self.logic = logic.lower()
 
         # Compile the set of Conditions

+ 2 - 1
netbox/extras/dashboard/utils.py

@@ -2,6 +2,7 @@ import uuid
 
 from django.conf import settings
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 
 from netbox.registry import registry
 from extras.constants import DEFAULT_DASHBOARD
@@ -32,7 +33,7 @@ def get_widget_class(name):
     try:
         return registry['widgets'][name]
     except KeyError:
-        raise ValueError(f"Unregistered widget class: {name}")
+        raise ValueError(_("Unregistered widget class: {name}").format(name=name))
 
 
 def get_dashboard(user):

+ 5 - 3
netbox/extras/dashboard/widgets.py

@@ -112,7 +112,9 @@ class DashboardWidget:
         Params:
             request: The current request
         """
-        raise NotImplementedError(f"{self.__class__} must define a render() method.")
+        raise NotImplementedError(_("{class_name} must define a render() method.").format(
+            class_name=self.__class__
+        ))
 
     @property
     def name(self):
@@ -178,7 +180,7 @@ class ObjectCountsWidget(DashboardWidget):
                 try:
                     dict(data)
                 except TypeError:
-                    raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
+                    raise forms.ValidationError(_("Invalid format. Object filters must be passed as a dictionary."))
             return data
 
     def render(self, request):
@@ -232,7 +234,7 @@ class ObjectListWidget(DashboardWidget):
                 try:
                     urlencode(data)
                 except (TypeError, ValueError):
-                    raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
+                    raise forms.ValidationError(_("Invalid format. URL parameters must be passed as a dictionary."))
             return data
 
     def render(self, request):

+ 5 - 2
netbox/extras/events.py

@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils import timezone
 from django.utils.module_loading import import_string
+from django.utils.translation import gettext as _
 from django_rq import get_queue
 
 from core.models import Job
@@ -129,7 +130,9 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
             )
 
         else:
-            raise ValueError(f"Unknown action type for an event rule: {event_rule.action_type}")
+            raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
+                action_type=event_rule.action_type
+            ))
 
 
 def process_event_queue(events):
@@ -175,4 +178,4 @@ def flush_events(queue):
                 func = import_string(name)
                 func(queue)
             except Exception as e:
-                logger.error(f"Cannot import events pipeline {name} error: {e}")
+                logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))

+ 2 - 2
netbox/extras/forms/bulk_import.py

@@ -202,7 +202,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 try:
                     webhook = Webhook.objects.get(name=action_object)
                 except Webhook.DoesNotExist:
-                    raise forms.ValidationError(f"Webhook {action_object} not found")
+                    raise forms.ValidationError(_("Webhook {name} not found").format(name=action_object))
                 self.instance.action_object = webhook
             # Script
             elif action_type == EventRuleActionChoices.SCRIPT:
@@ -211,7 +211,7 @@ class EventRuleImportForm(NetBoxModelImportForm):
                 try:
                     module, script = get_module_and_script(module_name, script_name)
                 except ObjectDoesNotExist:
-                    raise forms.ValidationError(f"Script {action_object} not found")
+                    raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
                 self.instance.action_object = module
                 self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
                 self.instance.action_parameters = {

+ 2 - 1
netbox/extras/management/commands/reindex.py

@@ -1,5 +1,6 @@
 from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand, CommandError
+from django.utils.translation import gettext as _
 
 from netbox.registry import registry
 from netbox.search.backends import search_backend
@@ -62,7 +63,7 @@ class Command(BaseCommand):
         # Determine which models to reindex
         indexers = self._get_indexers(*model_labels)
         if not indexers:
-            raise CommandError("No indexers found!")
+            raise CommandError(_("No indexers found!"))
         self.stdout.write(f'Reindexing {len(indexers)} models.')
 
         # Clear all cached values for the specified models (if not being lazy)

+ 17 - 0
netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py

@@ -0,0 +1,17 @@
+# Generated by Django 4.2.9 on 2024-02-20 17:15
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0106_bookmark_user_cascade_deletion'),
+    ]
+
+    operations = [
+        migrations.AddIndex(
+            model_name='cachedvalue',
+            index=models.Index(fields=['object_type', 'object_id'], name='extras_cachedvalue_object'),
+        ),
+    ]

+ 3 - 0
netbox/extras/models/search.py

@@ -57,6 +57,9 @@ class CachedValue(models.Model):
         ordering = ('weight', 'object_type', 'value', 'object_id')
         verbose_name = _('cached value')
         verbose_name_plural = _('cached values')
+        indexes = (
+            models.Index(fields=('object_type', 'object_id'), name='extras_cachedvalue_object'),
+        )
 
     def __str__(self):
         return f'{self.object_type} {self.object_id}: {self.field}={self.value}'

+ 4 - 3
netbox/extras/scripts.py

@@ -11,6 +11,7 @@ from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import transaction
 from django.utils.functional import classproperty
+from django.utils.translation import gettext as _
 
 from core.choices import JobStatusChoices
 from core.models import Job
@@ -356,7 +357,7 @@ class BaseScript:
         return ordered_vars
 
     def run(self, data, commit):
-        raise NotImplementedError("The script must define a run() method.")
+        raise NotImplementedError(_("The script must define a run() method."))
 
     # Form rendering
 
@@ -367,11 +368,11 @@ class BaseScript:
             fieldsets.extend(self.fieldsets)
         else:
             fields = list(name for name, _ in self._get_vars().items())
-            fieldsets.append(('Script Data', fields))
+            fieldsets.append((_('Script Data'), fields))
 
         # Append the default fieldset if defined in the Meta class
         exec_parameters = ('_schedule_at', '_interval', '_commit') if self.scheduling_enabled else ('_commit',)
-        fieldsets.append(('Script Execution Parameters', exec_parameters))
+        fieldsets.append((_('Script Execution Parameters'), exec_parameters))
 
         return fieldsets
 

+ 38 - 34
netbox/extras/signals.py

@@ -1,8 +1,8 @@
-import importlib
 import logging
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
 from django.utils.translation import gettext_lazy as _
@@ -12,9 +12,10 @@ from core.signals import job_end, job_start
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.models import EventRule
-from extras.validators import CustomValidator
+from extras.validators import run_validators
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
+from netbox.models.features import ChangeLoggingMixin
 from netbox.signals import post_clean
 from utilities.exceptions import AbortRequest
 from .choices import ObjectChangeActionChoices
@@ -68,7 +69,7 @@ def handle_changed_object(sender, instance, **kwargs):
     else:
         return
 
-    # Create/update an ObejctChange record for this change
+    # Create/update an ObjectChange record for this change
     objectchange = instance.to_objectchange(action)
     # If this is a many-to-many field change, check for a previous ObjectChange instance recorded
     # for this object by this request and update it
@@ -108,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
     """
     Fires when an object is deleted.
     """
+    # Run any deletion protection rules for the object. Note that this must occur prior
+    # to queueing any events for the object being deleted, in case a validation error is
+    # raised, causing the deletion to fail.
+    model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
+    validators = get_config().PROTECTION_RULES.get(model_name, [])
+    try:
+        run_validators(instance, validators)
+    except ValidationError as e:
+        raise AbortRequest(
+            _("Deletion is prevented by a protection rule: {message}").format(message=e)
+        )
+
     # Get the current request, or bail if not set
     request = current_request.get()
     if request is None:
@@ -122,6 +135,25 @@ def handle_deleted_object(sender, instance, **kwargs):
         objectchange.request_id = request.id
         objectchange.save()
 
+    # Django does not automatically send an m2m_changed signal for the reverse direction of a
+    # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
+    # trigger one manually. We do this by checking for any reverse M2M relationships on the
+    # instance being deleted, and explicitly call .remove() on the remote M2M field to delete
+    # the association. This triggers an m2m_changed signal with the `post_remove` action type
+    # for the forward direction of the relationship, ensuring that the change is recorded.
+    for relation in instance._meta.related_objects:
+        if type(relation) is not ManyToManyRel:
+            continue
+        related_model = relation.related_model
+        related_field_name = relation.remote_field.name
+        if not issubclass(related_model, ChangeLoggingMixin):
+            # We only care about triggering the m2m_changed signal for models which support
+            # change logging
+            continue
+        for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
+            obj.snapshot()  # Ensure the change record includes the "before" state
+            getattr(obj, related_field_name).remove(instance)
+
     # Enqueue webhooks
     queue = events_queue.get()
     enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
@@ -186,45 +218,17 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
 # Custom validation
 #
 
-def run_validators(instance, validators):
-
-    for validator in validators:
-
-        # Loading a validator class by dotted path
-        if type(validator) is str:
-            module, cls = validator.rsplit('.', 1)
-            validator = getattr(importlib.import_module(module), cls)()
-
-        # Constructing a new instance on the fly from a ruleset
-        elif type(validator) is dict:
-            validator = CustomValidator(validator)
-
-        validator(instance)
-
-
 @receiver(post_clean)
 def run_save_validators(sender, instance, **kwargs):
+    """
+    Run any custom validation rules for the model prior to calling save().
+    """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
 
     run_validators(instance, validators)
 
 
-@receiver(pre_delete)
-def run_delete_validators(sender, instance, **kwargs):
-    model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().PROTECTION_RULES.get(model_name, [])
-
-    try:
-        run_validators(instance, validators)
-    except ValidationError as e:
-        raise AbortRequest(
-            _("Deletion is prevented by a protection rule: {message}").format(
-                message=e
-            )
-        )
-
-
 #
 # Tags
 #

+ 20 - 0
netbox/extras/validators.py

@@ -1,3 +1,5 @@
+import importlib
+
 from django.core import validators
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
@@ -149,3 +151,21 @@ class CustomValidator:
         if field is not None:
             raise ValidationError({field: message})
         raise ValidationError(message)
+
+
+def run_validators(instance, validators):
+    """
+    Run the provided iterable of validators for the instance.
+    """
+    for validator in validators:
+
+        # Loading a validator class by dotted path
+        if type(validator) is str:
+            module, cls = validator.rsplit('.', 1)
+            validator = getattr(importlib.import_module(module), cls)()
+
+        # Constructing a new instance on the fly from a ruleset
+        elif type(validator) is dict:
+            validator = CustomValidator(validator)
+
+        validator(instance)

+ 2 - 1
netbox/ipam/api/nested_serializers.py

@@ -116,10 +116,11 @@ class NestedFHRPGroupSerializer(WritableNestedSerializer):
 
 class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
+    group = NestedFHRPGroupSerializer()
 
     class Meta:
         model = models.FHRPGroupAssignment
-        fields = ['id', 'url', 'display', 'interface_type', 'interface_id', 'group_id', 'priority']
+        fields = ['id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority']
 
 
 #

+ 2 - 1
netbox/ipam/api/views.py

@@ -3,6 +3,7 @@ from copy import deepcopy
 from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import transaction
 from django.shortcuts import get_object_or_404
+from django.utils.translation import gettext as _
 from django_pglocks import advisory_lock
 from drf_spectacular.utils import extend_schema
 from netaddr import IPSet
@@ -379,7 +380,7 @@ class AvailablePrefixesView(AvailableObjectsView):
                     'vrf': parent.vrf.pk if parent.vrf else None,
                 })
             else:
-                raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)")
+                raise ValidationError(_("Insufficient space is available to accommodate the requested prefix size(s)"))
 
         return requested_objects
 

+ 2 - 1
netbox/ipam/fields.py

@@ -1,6 +1,7 @@
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator, MaxValueValidator
 from django.db import models
+from django.utils.translation import gettext as _
 from netaddr import AddrFormatError, IPNetwork
 
 from . import lookups, validators
@@ -32,7 +33,7 @@ class BaseIPField(models.Field):
             # Always return a netaddr.IPNetwork object. (netaddr.IPAddress does not provide a mask.)
             return IPNetwork(value)
         except AddrFormatError:
-            raise ValidationError("Invalid IP address format: {}".format(value))
+            raise ValidationError(_("Invalid IP address format: {address}").format(address=value))
         except (TypeError, ValueError) as e:
             raise ValidationError(e)
 

+ 8 - 7
netbox/ipam/formfields.py

@@ -1,6 +1,7 @@
 from django import forms
 from django.core.exceptions import ValidationError
 from django.core.validators import validate_ipv4_address, validate_ipv6_address
+from django.utils.translation import gettext_lazy as _
 from netaddr import IPAddress, IPNetwork, AddrFormatError
 
 
@@ -10,7 +11,7 @@ from netaddr import IPAddress, IPNetwork, AddrFormatError
 
 class IPAddressFormField(forms.Field):
     default_error_messages = {
-        'invalid': "Enter a valid IPv4 or IPv6 address (without a mask).",
+        'invalid': _("Enter a valid IPv4 or IPv6 address (without a mask)."),
     }
 
     def to_python(self, value):
@@ -28,19 +29,19 @@ class IPAddressFormField(forms.Field):
             try:
                 validate_ipv6_address(value)
             except ValidationError:
-                raise ValidationError("Invalid IPv4/IPv6 address format: {}".format(value))
+                raise ValidationError(_("Invalid IPv4/IPv6 address format: {address}").format(address=value))
 
         try:
             return IPAddress(value)
         except ValueError:
-            raise ValidationError('This field requires an IP address without a mask.')
+            raise ValidationError(_('This field requires an IP address without a mask.'))
         except AddrFormatError:
-            raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
+            raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))
 
 
 class IPNetworkFormField(forms.Field):
     default_error_messages = {
-        'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).",
+        'invalid': _("Enter a valid IPv4 or IPv6 address (with CIDR mask)."),
     }
 
     def to_python(self, value):
@@ -52,9 +53,9 @@ class IPNetworkFormField(forms.Field):
 
         # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128.
         if len(value.split('/')) != 2:
-            raise ValidationError('CIDR mask (e.g. /24) is required.')
+            raise ValidationError(_('CIDR mask (e.g. /24) is required.'))
 
         try:
             return IPNetwork(value)
         except AddrFormatError:
-            raise ValidationError("Please specify a valid IPv4 or IPv6 address.")
+            raise ValidationError(_("Please specify a valid IPv4 or IPv6 address."))

+ 1 - 1
netbox/ipam/forms/model_forms.py

@@ -751,4 +751,4 @@ class ServiceCreateForm(ServiceForm):
             if not self.cleaned_data['description']:
                 self.cleaned_data['description'] = service_template.description
         elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
-            raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
+            raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template."))

+ 1 - 1
netbox/ipam/tests/test_api.py

@@ -760,7 +760,7 @@ class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
 
 class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
     model = FHRPGroupAssignment
-    brief_fields = ['display', 'group_id', 'id', 'interface_id', 'interface_type', 'priority', 'url']
+    brief_fields = ['display', 'group', 'id', 'interface_id', 'interface_type', 'priority', 'url']
     bulk_update_data = {
         'priority': 100,
     }

+ 9 - 4
netbox/ipam/validators.py

@@ -1,14 +1,19 @@
 from django.core.exceptions import ValidationError
 from django.core.validators import BaseValidator, RegexValidator
+from django.utils.translation import gettext_lazy as _
 
 
 def prefix_validator(prefix):
     if prefix.ip != prefix.cidr.ip:
-        raise ValidationError("{} is not a valid prefix. Did you mean {}?".format(prefix, prefix.cidr))
+        raise ValidationError(
+            _("{prefix} is not a valid prefix. Did you mean {suggested}?").format(
+                prefix=prefix, suggested=prefix.cidr
+            )
+        )
 
 
 class MaxPrefixLengthValidator(BaseValidator):
-    message = 'The prefix length must be less than or equal to %(limit_value)s.'
+    message = _('The prefix length must be less than or equal to %(limit_value)s.')
     code = 'max_prefix_length'
 
     def compare(self, a, b):
@@ -16,7 +21,7 @@ class MaxPrefixLengthValidator(BaseValidator):
 
 
 class MinPrefixLengthValidator(BaseValidator):
-    message = 'The prefix length must be greater than or equal to %(limit_value)s.'
+    message = _('The prefix length must be greater than or equal to %(limit_value)s.')
     code = 'min_prefix_length'
 
     def compare(self, a, b):
@@ -25,6 +30,6 @@ class MinPrefixLengthValidator(BaseValidator):
 
 DNSValidator = RegexValidator(
     regex=r'^([0-9A-Za-z_-]+|\*)(\.[0-9A-Za-z_-]+)*\.?$',
-    message='Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names',
+    message=_('Only alphanumeric characters, asterisks, hyphens, periods, and underscores are allowed in DNS names'),
     code='invalid'
 )

+ 6 - 5
netbox/netbox/api/fields.py

@@ -1,4 +1,5 @@
 from django.core.exceptions import ObjectDoesNotExist
+from django.utils.translation import gettext as _
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.types import OpenApiTypes
 from netaddr import IPNetwork
@@ -58,11 +59,11 @@ class ChoiceField(serializers.Field):
         if data == '':
             if self.allow_blank:
                 return data
-            raise ValidationError("This field may not be blank.")
+            raise ValidationError(_("This field may not be blank."))
 
         # Provide an explicit error message if the request is trying to write a dict or list
         if isinstance(data, (dict, list)):
-            raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.')
+            raise ValidationError(_('Value must be passed directly (e.g. "foo": 123); do not use a dictionary or list.'))
 
         # Check for string representations of boolean/integer values
         if hasattr(data, 'lower'):
@@ -82,7 +83,7 @@ class ChoiceField(serializers.Field):
         except TypeError:  # Input is an unhashable type
             pass
 
-        raise ValidationError(f"{data} is not a valid choice.")
+        raise ValidationError(_("{value} is not a valid choice.").format(value=data))
 
     @property
     def choices(self):
@@ -95,8 +96,8 @@ class ContentTypeField(RelatedField):
     Represent a ContentType as '<app_label>.<model>'
     """
     default_error_messages = {
-        "does_not_exist": "Invalid content type: {content_type}",
-        "invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
+        "does_not_exist": _("Invalid content type: {content_type}"),
+        "invalid": _("Invalid value. Specify a content type as '<app_label>.<model_name>'."),
     }
 
     def to_internal_value(self, data):

+ 11 - 5
netbox/netbox/api/serializers/nested.py

@@ -1,4 +1,5 @@
 from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
+from django.utils.translation import gettext_lazy as _
 from rest_framework import serializers
 from rest_framework.exceptions import ValidationError
 
@@ -30,9 +31,12 @@ class WritableNestedSerializer(BaseModelSerializer):
             try:
                 return queryset.get(**params)
             except ObjectDoesNotExist:
-                raise ValidationError(f"Related object not found using the provided attributes: {params}")
+                raise ValidationError(
+                    _("Related object not found using the provided attributes: {params}").format(params=params))
             except MultipleObjectsReturned:
-                raise ValidationError(f"Multiple objects match the provided attributes: {params}")
+                raise ValidationError(
+                    _("Multiple objects match the provided attributes: {params}").format(params=params)
+                )
             except FieldError as e:
                 raise ValidationError(e)
 
@@ -42,15 +46,17 @@ class WritableNestedSerializer(BaseModelSerializer):
             pk = int(data)
         except (TypeError, ValueError):
             raise ValidationError(
-                f"Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
-                f"unrecognized value: {data}"
+                _(
+                    "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an "
+                    "unrecognized value: {value}"
+                ).format(value=data)
             )
 
         # Look up object by PK
         try:
             return self.Meta.model.objects.get(pk=pk)
         except ObjectDoesNotExist:
-            raise ValidationError(f"Related object not found using the provided numeric ID: {pk}")
+            raise ValidationError(_("Related object not found using the provided numeric ID: {id}").format(id=pk))
 
 
 # Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers

+ 5 - 1
netbox/netbox/authentication.py

@@ -7,6 +7,7 @@ from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _Rem
 from django.contrib.auth.models import Group, AnonymousUser
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
 
 from users.constants import CONSTRAINT_TOKEN_USER
 from users.models import ObjectPermission
@@ -42,6 +43,7 @@ AUTH_BACKEND_ATTRS = {
     'hubspot': ('HubSpot', 'hubspot'),
     'keycloak': ('Keycloak', None),
     'microsoft-graph': ('Microsoft Graph', 'microsoft'),
+    'oidc': ('OpenID Connect', None),
     'okta': ('Okta', None),
     'okta-openidconnect': ('Okta (OIDC)', None),
     'salesforce-oauth2': ('Salesforce', 'salesforce'),
@@ -132,7 +134,9 @@ class ObjectPermissionMixin:
         # Sanity check: Ensure that the requested permission applies to the specified object
         model = obj._meta.concrete_model
         if model._meta.label_lower != '.'.join((app_label, model_name)):
-            raise ValueError(f"Invalid permission {perm} for model {model}")
+            raise ValueError(_("Invalid permission {permission} for model {model}").format(
+                permission=perm, model=model
+            ))
 
         # Compile a QuerySet filter that matches all instances of the specified model
         tokens = {

+ 2 - 1
netbox/netbox/config/__init__.py

@@ -4,6 +4,7 @@ import threading
 from django.conf import settings
 from django.core.cache import cache
 from django.db.utils import DatabaseError
+from django.utils.translation import gettext_lazy as _
 
 from .parameters import PARAMS
 
@@ -63,7 +64,7 @@ class Config:
         if item in self.defaults:
             return self.defaults[item]
 
-        raise AttributeError(f"Invalid configuration parameter: {item}")
+        raise AttributeError(_("Invalid configuration parameter: {item}").format(item=item))
 
     def _populate_from_cache(self):
         """Populate config data from Redis cache"""

+ 3 - 1
netbox/netbox/forms/mixins.py

@@ -35,7 +35,9 @@ class CustomFieldsMixin:
         Return the ContentType of the form's model.
         """
         if not getattr(self, 'model', None):
-            raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
+            raise NotImplementedError(_("{class_name} must specify a model class.").format(
+                class_name=self.__class__.__name__
+            ))
         return ContentType.objects.get_for_model(self.model)
 
     def _get_custom_fields(self, content_type):

+ 13 - 7
netbox/netbox/models/features.py

@@ -275,16 +275,20 @@ class CustomFieldsMixin(models.Model):
         # Validate all field values
         for field_name, value in self.custom_field_data.items():
             if field_name not in custom_fields:
-                raise ValidationError(f"Unknown field name '{field_name}' in custom field data.")
+                raise ValidationError(_("Unknown field name '{name}' in custom field data.").format(
+                    name=field_name
+                ))
             try:
                 custom_fields[field_name].validate(value)
             except ValidationError as e:
-                raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}")
+                raise ValidationError(_("Invalid value for custom field '{name}': {error}").format(
+                    name=field_name, error=e.message
+                ))
 
         # Check for missing required values
         for cf in custom_fields.values():
             if cf.required and cf.name not in self.custom_field_data:
-                raise ValidationError(f"Missing required custom field '{cf.name}'.")
+                raise ValidationError(_("Missing required custom field '{name}'.").format(name=cf.name))
 
 
 class CustomLinksMixin(models.Model):
@@ -489,10 +493,10 @@ class SyncedDataMixin(models.Model):
         # Create/delete AutoSyncRecord as needed
         content_type = ContentType.objects.get_for_model(self)
         if self.auto_sync_enabled:
-            AutoSyncRecord.objects.get_or_create(
-                datafile=self.data_file,
+            AutoSyncRecord.objects.update_or_create(
                 object_type=content_type,
-                object_id=self.pk
+                object_id=self.pk,
+                defaults={'datafile': self.data_file}
             )
         else:
             AutoSyncRecord.objects.filter(
@@ -547,7 +551,9 @@ class SyncedDataMixin(models.Model):
         Inheriting models must override this method with specific logic to copy data from the assigned DataFile
         to the local instance. This method should *NOT* call save() on the instance.
         """
-        raise NotImplementedError(f"{self.__class__} must implement a sync_data() method.")
+        raise NotImplementedError(_("{class_name} must implement a sync_data() method.").format(
+            class_name=self.__class__
+        ))
 
 
 #

+ 5 - 4
netbox/netbox/plugins/navigation.py

@@ -1,6 +1,7 @@
 from netbox.navigation import MenuGroup
 from utilities.choices import ButtonColorChoices
 from django.utils.text import slugify
+from django.utils.translation import gettext as _
 
 __all__ = (
     'PluginMenu',
@@ -42,11 +43,11 @@ class PluginMenuItem:
         self.staff_only = staff_only
         if permissions is not None:
             if type(permissions) not in (list, tuple):
-                raise TypeError("Permissions must be passed as a tuple or list.")
+                raise TypeError(_("Permissions must be passed as a tuple or list."))
             self.permissions = permissions
         if buttons is not None:
             if type(buttons) not in (list, tuple):
-                raise TypeError("Buttons must be passed as a tuple or list.")
+                raise TypeError(_("Buttons must be passed as a tuple or list."))
             self.buttons = buttons
 
 
@@ -64,9 +65,9 @@ class PluginMenuButton:
         self.icon_class = icon_class
         if permissions is not None:
             if type(permissions) not in (list, tuple):
-                raise TypeError("Permissions must be passed as a tuple or list.")
+                raise TypeError(_("Permissions must be passed as a tuple or list."))
             self.permissions = permissions
         if color is not None:
             if color not in ButtonColorChoices.values():
-                raise ValueError("Button color must be a choice within ButtonColorChoices.")
+                raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
             self.color = color

+ 25 - 6
netbox/netbox/plugins/registration.py

@@ -1,5 +1,6 @@
 import inspect
 
+from django.utils.translation import gettext_lazy as _
 from netbox.registry import registry
 from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem
 from .templates import PluginTemplateExtension
@@ -20,18 +21,32 @@ def register_template_extensions(class_list):
     # Validation
     for template_extension in class_list:
         if not inspect.isclass(template_extension):
-            raise TypeError(f"PluginTemplateExtension class {template_extension} was passed as an instance!")
+            raise TypeError(
+                _("PluginTemplateExtension class {template_extension} was passed as an instance!").format(
+                    template_extension=template_extension
+                )
+            )
         if not issubclass(template_extension, PluginTemplateExtension):
-            raise TypeError(f"{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!")
+            raise TypeError(
+                _("{template_extension} is not a subclass of netbox.plugins.PluginTemplateExtension!").format(
+                    template_extension=template_extension
+                )
+            )
         if template_extension.model is None:
-            raise TypeError(f"PluginTemplateExtension class {template_extension} does not define a valid model!")
+            raise TypeError(
+                _("PluginTemplateExtension class {template_extension} does not define a valid model!").format(
+                    template_extension=template_extension
+                )
+            )
 
         registry['plugins']['template_extensions'][template_extension.model].append(template_extension)
 
 
 def register_menu(menu):
     if not isinstance(menu, PluginMenu):
-        raise TypeError(f"{menu} must be an instance of netbox.plugins.PluginMenu")
+        raise TypeError(_("{item} must be an instance of netbox.plugins.PluginMenuItem").format(
+            item=menu_link
+        ))
     registry['plugins']['menus'].append(menu)
 
 
@@ -42,10 +57,14 @@ def register_menu_items(section_name, class_list):
     # Validation
     for menu_link in class_list:
         if not isinstance(menu_link, PluginMenuItem):
-            raise TypeError(f"{menu_link} must be an instance of netbox.plugins.PluginMenuItem")
+            raise TypeError(_("{menu_link} must be an instance of netbox.plugins.PluginMenuItem").format(
+                menu_link=menu_link
+            ))
         for button in menu_link.buttons:
             if not isinstance(button, PluginMenuButton):
-                raise TypeError(f"{button} must be an instance of netbox.plugins.PluginMenuButton")
+                raise TypeError(_("{button} must be an instance of netbox.plugins.PluginMenuButton").format(
+                    button=button
+                ))
 
     registry['plugins']['menu_items'][section_name] = class_list
 

+ 2 - 1
netbox/netbox/plugins/templates.py

@@ -1,4 +1,5 @@
 from django.template.loader import get_template
+from django.utils.translation import gettext as _
 
 __all__ = (
     'PluginTemplateExtension',
@@ -31,7 +32,7 @@ class PluginTemplateExtension:
         if extra_context is None:
             extra_context = {}
         elif not isinstance(extra_context, dict):
-            raise TypeError("extra_context must be a dictionary")
+            raise TypeError(_("extra_context must be a dictionary"))
 
         return get_template(template_name).render({**self.context, **extra_context})
 

+ 4 - 3
netbox/netbox/registry.py

@@ -1,4 +1,5 @@
 import collections
+from django.utils.translation import gettext as _
 
 
 class Registry(dict):
@@ -10,13 +11,13 @@ class Registry(dict):
         try:
             return super().__getitem__(key)
         except KeyError:
-            raise KeyError(f"Invalid store: {key}")
+            raise KeyError(_("Invalid store: {key}").format(key=key))
 
     def __setitem__(self, key, value):
-        raise TypeError("Cannot add stores to registry after initialization")
+        raise TypeError(_("Cannot add stores to registry after initialization"))
 
     def __delitem__(self, key):
-        raise TypeError("Cannot delete stores from registry")
+        raise TypeError(_("Cannot delete stores from registry"))
 
 
 # Initialize the global registry

+ 1 - 2
netbox/netbox/settings.py

@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
 # Environment setup
 #
 
-VERSION = '3.7.2'
+VERSION = '3.7.3'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -571,7 +571,6 @@ SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.social_auth.social_uid',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.user.get_username',
-    'social_core.pipeline.social_auth.associate_by_email',
     'social_core.pipeline.user.create_user',
     'social_core.pipeline.social_auth.associate_user',
     'netbox.authentication.user_default_groups_handler',

+ 10 - 6
netbox/netbox/views/generic/bulk_views.py

@@ -14,6 +14,7 @@ from django.http import HttpResponse
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 from django_tables2.export import TableExport
 
 from extras.models import ExportTemplate
@@ -320,7 +321,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             if type(field.widget) is not HiddenInput
         }
 
-    def _save_object(self, model_form, request):
+    def _save_object(self, import_form, model_form, request):
 
         # Save the primary object
         obj = self.save_object(model_form, request)
@@ -345,11 +346,14 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     related_obj = f.save()
                     related_obj_pks.append(related_obj.pk)
                 else:
-                    # Replicate errors on the related object form to the primary form for display
+                    # Replicate errors on the related object form to the import form for display and abort
                     for subfield_name, errors in f.errors.items():
                         for err in errors:
-                            err_msg = "{}[{}] {}: {}".format(field_name, i, subfield_name, err)
-                            model_form.add_error(None, err_msg)
+                            if subfield_name == '__all__':
+                                err_msg = f"{field_name}[{i}]: {err}"
+                            else:
+                                err_msg = f"{field_name}[{i}] {subfield_name}: {err}"
+                            import_form.add_error(None, err_msg)
                     raise AbortTransaction()
 
             # Enforce object-level permissions on related objects
@@ -390,7 +394,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                 try:
                     instance = prefetched_objects[object_id]
                 except KeyError:
-                    form.add_error('data', f"Row {i}: Object with ID {object_id} does not exist")
+                    form.add_error('data', _("Row {i}: Object with ID {id} does not exist").format(i=i, id=object_id))
                     raise ValidationError('')
 
                 # Take a snapshot for change logging
@@ -416,7 +420,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
             restrict_form_fields(model_form, request.user)
 
             if model_form.is_valid():
-                obj = self._save_object(model_form, request)
+                obj = self._save_object(form, model_form, request)
                 saved_objects.append(obj)
             else:
                 # Replicate model form errors for display

+ 4 - 1
netbox/netbox/views/generic/object_views.py

@@ -11,6 +11,7 @@ from django.shortcuts import redirect, render
 from django.urls import reverse
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
+from django.utils.translation import gettext as _
 
 from extras.signals import clear_events
 from utilities.error_handlers import handle_protectederror
@@ -101,7 +102,9 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
             request: The current request
             parent: The parent object
         """
-        raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
+        raise NotImplementedError(_('{class_name} must implement get_children()').format(
+            class_name=self.__class__.__name__
+        ))
 
     def prep_table_data(self, request, queryset, parent):
         """

+ 4 - 0
netbox/templates/account/profile.html

@@ -34,6 +34,10 @@
               <th scope="row">{% trans "Account Created" %}</th>
               <td>{{ request.user.date_joined|annotated_date }}</td>
             </tr>
+            <tr>
+              <th scope="row">{% trans "Last Login" %}</th>
+              <td>{{ request.user.last_login|annotated_date }}</td>
+            </tr>
             <tr>
               <th scope="row">{% trans "Superuser" %}</th>
               <td>{% checkmark request.user.is_superuser %}</td>

+ 1 - 1
netbox/templates/dcim/devicetype.html

@@ -41,7 +41,7 @@
                             <td>{{ object.u_height|floatformat }}</td>
                         </tr>
                         <tr>
-                            <td>{% trans "Exclude From Utilization" %})</td>
+                            <td>{% trans "Exclude From Utilization" %}</td>
                             <td>{% checkmark object.exclude_from_utilization %}</td>
                         </tr>
                         <tr>

+ 4 - 0
netbox/templates/users/user.html

@@ -30,6 +30,10 @@
               <th scope="row">{% trans "Account Created" %}</th>
               <td>{{ object.date_joined|annotated_date }}</td>
             </tr>
+            <tr>
+              <th scope="row">{% trans "Last Login" %}</th>
+              <td>{{ object.last_login|annotated_date }}</td>
+            </tr>
             <tr>
               <th scope="row">{% trans "Active" %}</th>
               <td>{% checkmark object.is_active %}</td>

+ 2 - 2
netbox/templates/vpn/l2vpntermination_edit.html

@@ -13,7 +13,7 @@
       <div class="offset-sm-3">
         <ul class="nav nav-pills" role="tablist">
           <li role="presentation" class="nav-item">
-            <button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}">
+            <button role="tab" type="button" id="vlan_tab" data-bs-toggle="tab" aria-controls="vlan" data-bs-target="#vlan" class="nav-link {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}">
               {% trans "VLAN" %}
             </button>
           </li>
@@ -32,7 +32,7 @@
     </div>
     <div class="row mb-3">
       <div class="tab-content p-0 border-0">
-        <div class="tab-pane {% if not form.initial.interface or form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
+        <div class="tab-pane {% if not form.initial.interface and not form.initial.vminterface %}active{% endif %}" id="vlan" role="tabpanel" aria-labeled-by="vlan_tab">
           {% render_field form.vlan %}
         </div>
         <div class="tab-pane {% if form.initial.interface %}active{% endif %}" id="interface" role="tabpanel" aria-labeled-by="interface_tab">

ファイルの差分が大きいため隠しています
+ 256 - 211
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 273 - 217
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 276 - 217
netbox/translations/fr/LC_MESSAGES/django.po


BIN
netbox/translations/ja/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 276 - 231
netbox/translations/ja/LC_MESSAGES/django.po


BIN
netbox/translations/pt/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 274 - 217
netbox/translations/pt/LC_MESSAGES/django.po


BIN
netbox/translations/ru/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 276 - 222
netbox/translations/ru/LC_MESSAGES/django.po


BIN
netbox/translations/tr/LC_MESSAGES/django.mo


ファイルの差分が大きいため隠しています
+ 267 - 216
netbox/translations/tr/LC_MESSAGES/django.po


+ 1 - 1
netbox/users/api/serializers.py

@@ -35,7 +35,7 @@ class UserSerializer(ValidatedModelSerializer):
         model = get_user_model()
         fields = (
             'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active',
-            'date_joined', 'groups',
+            'date_joined', 'last_login', 'groups',
         )
         extra_kwargs = {
             'password': {'write_only': True}

+ 7 - 1
netbox/users/forms/model_forms.py

@@ -380,12 +380,18 @@ class ObjectPermissionForm(BootstrapMixin, forms.ModelForm):
                 constraints = [constraints]
             for ct in object_types:
                 model = ct.model_class()
+
+                if model._meta.model_name in ['script', 'report']:
+                    raise forms.ValidationError({
+                        'constraints': _('Constraints are not supported for this object type.')
+                    })
+
                 try:
                     tokens = {
                         CONSTRAINT_TOKEN_USER: 0,  # Replace token with a null user ID
                     }
                     model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists()
-                except FieldError as e:
+                except (FieldError, ValueError) as e:
                     raise forms.ValidationError({
                         'constraints': _('Invalid filter for {model}: {error}').format(model=model, error=e)
                     })

+ 1 - 1
netbox/utilities/fields.py

@@ -93,7 +93,7 @@ class RestrictedGenericForeignKey(GenericForeignKey):
         if type(queryset) is dict:
             restrict_params = queryset
         elif queryset is not None:
-            raise ValueError("Custom queryset can't be used for this lookup.")
+            raise ValueError(_("Custom queryset can't be used for this lookup."))
 
         # For efficiency, group the instances by content type and then do one
         # query per model

+ 2 - 2
netbox/utilities/forms/bulk_import.py

@@ -49,7 +49,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
 
         # Determine whether we're reading from form data or an uploaded file
         if self.cleaned_data['data'] and import_method != ImportMethodChoices.DIRECT:
-            raise forms.ValidationError("Form data must be empty when uploading/selecting a file.")
+            raise forms.ValidationError(_("Form data must be empty when uploading/selecting a file."))
         if import_method == ImportMethodChoices.UPLOAD:
             self.upload_file = 'upload_file'
             file = self.files.get('upload_file')
@@ -78,7 +78,7 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
         elif format == ImportFormatChoices.YAML:
             self.cleaned_data['data'] = self._clean_yaml(data)
         else:
-            raise forms.ValidationError(f"Unknown data format: {format}")
+            raise forms.ValidationError(_("Unknown data format: {format}").format(format=format))
 
     def _detect_format(self, data):
         """

+ 2 - 0
netbox/utilities/forms/fields/fields.py

@@ -93,6 +93,8 @@ class JSONField(_JSONField):
     """
     Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
     """
+    empty_values = [None, '', ()]
+
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if not self.help_text:

+ 22 - 11
netbox/utilities/forms/utils.py

@@ -2,6 +2,7 @@ import re
 
 from django import forms
 from django.forms.models import fields_for_model
+from django.utils.translation import gettext as _
 
 from utilities.choices import unpack_grouped_choices
 from utilities.querysets import RestrictedQuerySet
@@ -38,7 +39,7 @@ def parse_numeric_range(string, base=10):
         try:
             begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
         except ValueError:
-            raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+            raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
         values.extend(range(begin, end))
     return sorted(set(values))
 
@@ -61,7 +62,7 @@ def parse_alphanumeric_range(string):
             begin, end = dash_range, dash_range
         if begin.isdigit() and end.isdigit():
             if int(begin) >= int(end):
-                raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
 
             for n in list(range(int(begin), int(end) + 1)):
                 values.append(n)
@@ -73,10 +74,10 @@ def parse_alphanumeric_range(string):
             else:
                 # Not a valid range (more than a single character)
                 if not len(begin) == len(end) == 1:
-                    raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                    raise forms.ValidationError(_('Range "{value}" is invalid.').format(value=dash_range))
 
                 if ord(begin) >= ord(end):
-                    raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
+                    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))
@@ -221,18 +222,24 @@ def parse_csv(reader):
         if '.' in header:
             field, to_field = header.split('.', 1)
             if field in headers:
-                raise forms.ValidationError(f'Duplicate or conflicting column header for "{field}"')
+                raise forms.ValidationError(_('Duplicate or conflicting column header for "{field}"').format(
+                    field=field
+                ))
             headers[field] = to_field
         else:
             if header in headers:
-                raise forms.ValidationError(f'Duplicate or conflicting column header for "{header}"')
+                raise forms.ValidationError(_('Duplicate or conflicting column header for "{header}"').format(
+                    header=header
+                ))
             headers[header] = None
 
     # Parse CSV rows into a list of dictionaries mapped from the column headers.
     for i, row in enumerate(reader, start=1):
         if len(row) != len(headers):
             raise forms.ValidationError(
-                f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
+                _("Row {row}: Expected {count_expected} columns but found {count_found}").format(
+                    row=i, count_expected=len(headers), count_found=len(row)
+                )
             )
         row = [col.strip() for col in row]
         record = dict(zip(headers.keys(), row))
@@ -253,14 +260,18 @@ def validate_csv(headers, fields, required_fields):
             is_update = True
             continue
         if field not in fields:
-            raise forms.ValidationError(f'Unexpected column header "{field}" found.')
+            raise forms.ValidationError(_('Unexpected column header "{field}" found.').format(field=field))
         if to_field and not hasattr(fields[field], 'to_field_name'):
-            raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
+            raise forms.ValidationError(_('Column "{field}" is not a related object; cannot use dots').format(
+                field=field
+            ))
         if to_field and not hasattr(fields[field].queryset.model, to_field):
-            raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
+            raise forms.ValidationError(_('Invalid related object attribute for column "{field}": {to_field}').format(
+                field=field, to_field=to_field
+            ))
 
     # Validate required fields (if not an update)
     if not is_update:
         for f in required_fields:
             if f not in headers:
-                raise forms.ValidationError(f'Required column header "{f}" not found.')
+                raise forms.ValidationError(_('Required column header "{header}" not found.').format(header=f))

+ 11 - 2
netbox/utilities/forms/widgets/apiselect.py

@@ -3,6 +3,7 @@ from typing import Dict, List, Tuple
 
 from django import forms
 from django.conf import settings
+from django.utils.translation import gettext_lazy as _
 
 __all__ = (
     'APISelect',
@@ -119,7 +120,11 @@ class APISelect(forms.Select):
                 update = [{'fieldName': f, 'queryParam': q} for (f, q) in self.dynamic_params.items()]
                 self._serialize_params(key, update)
             except IndexError as error:
-                raise RuntimeError(f"Missing required value for dynamic query param: '{self.dynamic_params}'") from error
+                raise RuntimeError(
+                    _("Missing required value for dynamic query param: '{dynamic_params}'").format(
+                        dynamic_params=self.dynamic_params
+                    )
+                ) from error
 
     def _add_static_params(self):
         """
@@ -132,7 +137,11 @@ class APISelect(forms.Select):
                 update = [{'queryParam': k, 'queryValue': v} for (k, v) in self.static_params.items()]
                 self._serialize_params(key, update)
             except IndexError as error:
-                raise RuntimeError(f"Missing required value for static query param: '{self.static_params}'") from error
+                raise RuntimeError(
+                    _("Missing required value for static query param: '{static_params}'").format(
+                        static_params=self.static_params
+                    )
+                ) from error
 
     def add_query_params(self, query_params):
         """

+ 3 - 2
netbox/utilities/permissions.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
+from django.utils.translation import gettext_lazy as _
 
 __all__ = (
     'get_permission_for_model',
@@ -36,7 +37,7 @@ def resolve_permission(name):
         action, model_name = codename.rsplit('_', 1)
     except ValueError:
         raise ValueError(
-            f"Invalid permission name: {name}. Must be in the format <app_label>.<action>_<model>"
+            _("Invalid permission name: {name}. Must be in the format <app_label>.<action>_<model>").format(name=name)
         )
 
     return app_label, action, model_name
@@ -53,7 +54,7 @@ def resolve_permission_ct(name):
     try:
         content_type = ContentType.objects.get(app_label=app_label, model=model_name)
     except ContentType.DoesNotExist:
-        raise ValueError(f"Unknown app_label/model_name for {name}")
+        raise ValueError(_("Unknown app_label/model_name for {name}").format(name=name))
 
     return content_type, action
 

+ 2 - 1
netbox/utilities/request.py

@@ -1,3 +1,4 @@
+from django.utils.translation import gettext_lazy as _
 from netaddr import AddrFormatError, IPAddress
 from urllib.parse import urlparse
 
@@ -29,7 +30,7 @@ def get_client_ip(request, additional_headers=()):
                 return IPAddress(ip)
             except AddrFormatError:
                 # We did our best
-                raise ValueError(f"Invalid IP address set for {header}: {ip}")
+                raise ValueError(_("Invalid IP address set for {header}: {ip}").format(header=header, ip=ip))
 
     # Could not determine the client IP address from request headers
     return None

+ 4 - 1
netbox/utilities/tables.py

@@ -1,3 +1,4 @@
+from django.utils.translation import gettext_lazy as _
 from netbox.registry import registry
 
 __all__ = (
@@ -43,5 +44,7 @@ def register_table_column(column, name, *tables):
     for table in tables:
         reg = registry['tables'][table]
         if name in reg:
-            raise ValueError(f"A column named {name} is already defined for table {table.__name__}")
+            raise ValueError(_("A column named {name} is already defined for table {table_name}").format(
+                name=name, table_name=table.__name__
+            ))
         reg[name] = column

+ 1 - 1
netbox/utilities/templates/buttons/export.html

@@ -25,7 +25,7 @@
         <hr class="dropdown-divider">
       </li>
       <li>
-        <a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_type={{ content_type.pk }}">{% trans "Add export template" %}...</a>
+        <a class="dropdown-item" href="{% url 'extras:exporttemplate_add' %}?content_types={{ content_type.pk }}">{% trans "Add export template" %}...</a>
       </li>
     {% endif %}
   </ul>

+ 2 - 1
netbox/utilities/testing/views.py

@@ -6,6 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.db.models import ForeignKey
 from django.test import override_settings
 from django.urls import reverse
+from django.utils.translation import gettext as _
 
 from extras.choices import ObjectChangeActionChoices
 from extras.models import ObjectChange
@@ -621,7 +622,7 @@ class ViewTestCases:
         @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
         def test_bulk_update_objects_with_permission(self):
             if not hasattr(self, 'csv_update_data'):
-                raise NotImplementedError("The test must define csv_update_data.")
+                raise NotImplementedError(_("The test must define csv_update_data."))
 
             initial_count = self._get_queryset().count()
             array, csv_data = self._get_update_csv_data()

+ 17 - 8
netbox/utilities/utils.py

@@ -15,6 +15,7 @@ from django.utils import timezone
 from django.utils.datastructures import MultiValueDict
 from django.utils.html import escape
 from django.utils.timezone import localtime
+from django.utils.translation import gettext as _
 from jinja2.sandbox import SandboxedEnvironment
 from mptt.models import MPTTModel
 
@@ -306,13 +307,17 @@ def to_meters(length, unit):
     """
     try:
         if length < 0:
-            raise ValueError("Length must be a positive number")
+            raise ValueError(_("Length must be a positive number"))
     except TypeError:
-        raise TypeError(f"Invalid value '{length}' for length (must be a number)")
+        raise TypeError(_("Invalid value '{length}' for length (must be a number)").format(length=length))
 
     valid_units = CableLengthUnitChoices.values()
     if unit not in valid_units:
-        raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
+        raise ValueError(
+            _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+                unit=unit, valid_units=', '.join(valid_units)
+            )
+        )
 
     if unit == CableLengthUnitChoices.UNIT_KILOMETER:
         return length * 1000
@@ -326,7 +331,7 @@ def to_meters(length, unit):
         return length * Decimal(0.3048)
     if unit == CableLengthUnitChoices.UNIT_INCH:
         return length * Decimal(0.0254)
-    raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.")
+    raise ValueError(_("Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.").format(unit=unit))
 
 
 def to_grams(weight, unit):
@@ -335,13 +340,17 @@ def to_grams(weight, unit):
     """
     try:
         if weight < 0:
-            raise ValueError("Weight must be a positive number")
+            raise ValueError(_("Weight must be a positive number"))
     except TypeError:
-        raise TypeError(f"Invalid value '{weight}' for weight (must be a number)")
+        raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
 
     valid_units = WeightUnitChoices.values()
     if unit not in valid_units:
-        raise ValueError(f"Unknown unit {unit}. Must be one of the following: {', '.join(valid_units)}")
+        raise ValueError(
+            _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
+                unit=unit, valid_units=', '.join(valid_units)
+            )
+        )
 
     if unit == WeightUnitChoices.UNIT_KILOGRAM:
         return weight * 1000
@@ -351,7 +360,7 @@ def to_grams(weight, unit):
         return weight * Decimal(453.592)
     if unit == WeightUnitChoices.UNIT_OUNCE:
         return weight * Decimal(28.3495)
-    raise ValueError(f"Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.")
+    raise ValueError(_("Unknown unit {unit}. Must be 'kg', 'g', 'lb', 'oz'.").format(unit=unit))
 
 
 def render_jinja2(template_code, context):

+ 2 - 1
netbox/utilities/validators.py

@@ -2,6 +2,7 @@ import re
 
 from django.core.exceptions import ValidationError
 from django.core.validators import BaseValidator, RegexValidator, URLValidator, _lazy_re_compile
+from django.utils.translation import gettext_lazy as _
 
 from netbox.config import get_config
 
@@ -61,4 +62,4 @@ def validate_regex(value):
     try:
         re.compile(value)
     except re.error:
-        raise ValidationError(f"{value} is not a valid regular expression.")
+        raise ValidationError(_("{value} is not a valid regular expression.").format(value=value))

+ 11 - 4
netbox/utilities/views.py

@@ -2,6 +2,7 @@ from django.contrib.auth.mixins import AccessMixin
 from django.core.exceptions import ImproperlyConfigured
 from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
+from django.utils.translation import gettext_lazy as _
 
 from netbox.registry import registry
 from .permissions import resolve_permission
@@ -34,7 +35,9 @@ class ContentTypePermissionRequiredMixin(AccessMixin):
         """
         Return the specific permission necessary to perform the requested action on an object.
         """
-        raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
+        raise NotImplementedError(_("{self.__class__.__name__} must implement get_required_permission()").format(
+            class_name=self.__class__.__name__
+        ))
 
     def has_permission(self):
         user = self.request.user
@@ -68,7 +71,9 @@ class ObjectPermissionRequiredMixin(AccessMixin):
         """
         Return the specific permission necessary to perform the requested action on an object.
         """
-        raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
+        raise NotImplementedError(_("{class_name} must implement get_required_permission()").format(
+            class_name=self.__class__.__name__
+        ))
 
     def has_permission(self):
         user = self.request.user
@@ -89,8 +94,10 @@ class ObjectPermissionRequiredMixin(AccessMixin):
 
         if not hasattr(self, 'queryset'):
             raise ImproperlyConfigured(
-                '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
-                'a base queryset'.format(self.__class__.__name__)
+                _(
+                    '{class_name} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views '
+                    'which define a base queryset'
+                ).format(class_name=self.__class__.__name__)
             )
 
         if not self.has_permission():

+ 2 - 2
netbox/virtualization/api/serializers.py

@@ -103,8 +103,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
         fields = [
             'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform',
             'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'description', 'comments',
-            'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
-            'interface_count', 'virtual_disk_count',
+            'config_template', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created',
+            'last_updated', 'interface_count', 'virtual_disk_count',
         ]
 
     @extend_schema_field(serializers.JSONField(allow_null=True))

+ 4 - 1
netbox/vpn/api/serializers.py

@@ -46,7 +46,10 @@ class TunnelSerializer(NetBoxModelSerializer):
     status = ChoiceField(
         choices=TunnelStatusChoices
     )
-    group = NestedTunnelGroupSerializer()
+    group = NestedTunnelGroupSerializer(
+        required=False,
+        allow_null=True
+    )
     encapsulation = ChoiceField(
         choices=TunnelEncapsulationChoices
     )

+ 7 - 3
netbox/vpn/tables/tunnels.py

@@ -40,6 +40,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
         verbose_name=_('Name'),
         linkify=True
     )
+    group = tables.Column(
+        verbose_name=_('Group'),
+        linkify=True
+    )
     status = columns.ChoiceFieldColumn(
         verbose_name=_('Status')
     )
@@ -63,10 +67,10 @@ class TunnelTable(TenancyColumnsMixin, NetBoxTable):
     class Meta(NetBoxTable.Meta):
         model = Tunnel
         fields = (
-            'pk', 'id', 'name', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group', 'tunnel_id',
-            'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
+            'pk', 'id', 'name', 'group', 'status', 'encapsulation', 'ipsec_profile', 'tenant', 'tenant_group',
+            'tunnel_id', 'termination_count', 'description', 'comments', 'tags', 'created', 'last_updated',
         )
-        default_columns = ('pk', 'name', 'status', 'encapsulation', 'tenant', 'terminations_count')
+        default_columns = ('pk', 'name', 'group', 'status', 'encapsulation', 'tenant', 'terminations_count')
 
 
 class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):

+ 0 - 1
netbox/vpn/tests/test_api.py

@@ -105,7 +105,6 @@ class TunnelTest(APIViewTestCases.APIViewTestCase):
             {
                 'name': 'Tunnel 6',
                 'status': TunnelStatusChoices.STATUS_DISABLED,
-                'group': tunnel_groups[1].pk,
                 'encapsulation': TunnelEncapsulationChoices.ENCAP_GRE,
             },
         ]

+ 3 - 2
netbox/wireless/utils.py

@@ -1,4 +1,5 @@
 from decimal import Decimal
+from django.utils.translation import gettext_lazy as _
 
 from .choices import WirelessChannelChoices
 
@@ -12,7 +13,7 @@ def get_channel_attr(channel, attr):
     Return the specified attribute of a given WirelessChannelChoices value.
     """
     if channel not in WirelessChannelChoices.values():
-        raise ValueError(f"Invalid channel value: {channel}")
+        raise ValueError(_("Invalid channel value: {channel}").format(channel=channel))
 
     channel_values = channel.split('-')
     attrs = {
@@ -22,6 +23,6 @@ def get_channel_attr(channel, attr):
         'width': Decimal(channel_values[3]),
     }
     if attr not in attrs:
-        raise ValueError(f"Invalid channel attribute: {attr}")
+        raise ValueError(_("Invalid channel attribute: {name}").format(name=attr))
 
     return attrs[attr]

+ 5 - 5
requirements.txt

@@ -1,5 +1,5 @@
 bleach==6.1.0
-Django==4.2.9
+Django==4.2.10
 django-cors-headers==4.3.1
 django-debug-toolbar==4.3.0
 django-filter==23.5
@@ -21,15 +21,15 @@ graphene-django==3.0.0
 gunicorn==21.2.0
 Jinja2==3.1.3
 Markdown==3.5.2
-mkdocs-material==9.5.7
+mkdocs-material==9.5.10
 mkdocstrings[python-legacy]==0.24.0
-netaddr==0.10.1
+netaddr==1.2.1
 Pillow==10.2.0
 psycopg[binary,pool]==3.1.18
 PyYAML==6.0.1
 requests==2.31.0
 social-auth-app-django==5.4.0
-social-auth-core[openidconnect]==4.5.2
+social-auth-core[openidconnect]==4.5.3
 svgwrite==1.4.3
 tablib==3.5.0
-tzdata==2023.4
+tzdata==2024.1

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません