Răsfoiți Sursa

9856 merge feature

Arthur 1 an în urmă
părinte
comite
13bf2c1940
100 a modificat fișierele cu 1135 adăugiri și 675 ștergeri
  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. 2 2
      docs/models/dcim/device.md
  7. 36 1
      docs/release-notes/version-3.7.md
  8. 8 0
      docs/release-notes/version-4.0.md
  9. 7 1
      netbox/circuits/api/serializers.py
  10. 4 0
      netbox/circuits/apps.py
  11. 2 2
      netbox/circuits/models/circuits.py
  12. 6 6
      netbox/circuits/tests/test_api.py
  13. 1 0
      netbox/core/api/schema.py
  14. 3 0
      netbox/core/api/serializers.py
  15. 5 1
      netbox/core/apps.py
  16. 1 1
      netbox/core/data_backends.py
  17. 2 2
      netbox/core/forms/model_forms.py
  18. 1 1
      netbox/core/models/config.py
  19. 2 2
      netbox/core/models/data.py
  20. 5 1
      netbox/core/models/jobs.py
  21. 1 1
      netbox/core/tests/test_api.py
  22. 1 1
      netbox/core/views.py
  23. 57 20
      netbox/dcim/api/serializers.py
  24. 7 5
      netbox/dcim/api/views.py
  25. 5 1
      netbox/dcim/apps.py
  26. 3 2
      netbox/dcim/fields.py
  27. 14 0
      netbox/dcim/filtersets.py
  28. 29 6
      netbox/dcim/forms/bulk_import.py
  29. 9 2
      netbox/dcim/forms/model_forms.py
  30. 18 10
      netbox/dcim/models/cables.py
  31. 2 2
      netbox/dcim/models/device_components.py
  32. 1 15
      netbox/dcim/models/devices.py
  33. 5 0
      netbox/dcim/tables/devices.py
  34. 1 1
      netbox/dcim/tables/template_code.py
  35. 42 41
      netbox/dcim/tests/test_api.py
  36. 1 1
      netbox/dcim/tests/test_cablepaths.py
  37. 12 0
      netbox/dcim/tests/test_filtersets.py
  38. 0 24
      netbox/dcim/tests/test_models.py
  39. 2 1
      netbox/extras/api/customfields.py
  40. 55 68
      netbox/extras/api/serializers.py
  41. 13 51
      netbox/extras/api/views.py
  42. 4 0
      netbox/extras/apps.py
  43. 12 6
      netbox/extras/conditions.py
  44. 2 1
      netbox/extras/dashboard/utils.py
  45. 5 3
      netbox/extras/dashboard/widgets.py
  46. 8 7
      netbox/extras/events.py
  47. 21 0
      netbox/extras/filtersets.py
  48. 4 7
      netbox/extras/forms/bulk_import.py
  49. 12 29
      netbox/extras/forms/model_forms.py
  50. 2 1
      netbox/extras/management/commands/reindex.py
  51. 17 0
      netbox/extras/migrations/0107_cachedvalue_extras_cachedvalue_object.py
  52. 1 1
      netbox/extras/migrations/0108_convert_reports_to_scripts.py
  53. 159 0
      netbox/extras/migrations/0109_script_model.py
  54. 15 0
      netbox/extras/migrations/0110_remove_eventrule_action_parameters.py
  55. 0 4
      netbox/extras/models/models.py
  56. 93 6
      netbox/extras/models/scripts.py
  57. 3 0
      netbox/extras/models/search.py
  58. 1 0
      netbox/extras/reports.py
  59. 5 6
      netbox/extras/scripts.py
  60. 38 34
      netbox/extras/signals.py
  61. 28 19
      netbox/extras/tests/test_api.py
  62. 9 4
      netbox/extras/urls.py
  63. 0 20
      netbox/extras/utils.py
  64. 20 0
      netbox/extras/validators.py
  65. 61 62
      netbox/extras/views.py
  66. 18 2
      netbox/ipam/api/serializers.py
  67. 2 1
      netbox/ipam/api/views.py
  68. 4 0
      netbox/ipam/apps.py
  69. 2 1
      netbox/ipam/fields.py
  70. 8 7
      netbox/ipam/formfields.py
  71. 1 1
      netbox/ipam/forms/model_forms.py
  72. 15 15
      netbox/ipam/tests/test_api.py
  73. 9 4
      netbox/ipam/validators.py
  74. 8 5
      netbox/netbox/api/fields.py
  75. 11 5
      netbox/netbox/api/serializers/nested.py
  76. 17 4
      netbox/netbox/api/viewsets/__init__.py
  77. 0 35
      netbox/netbox/api/viewsets/mixins.py
  78. 7 3
      netbox/netbox/authentication.py
  79. 2 1
      netbox/netbox/config/__init__.py
  80. 3 1
      netbox/netbox/forms/mixins.py
  81. 61 43
      netbox/netbox/models/features.py
  82. 3 3
      netbox/netbox/navigation/menu.py
  83. 5 0
      netbox/netbox/plugins/__init__.py
  84. 5 4
      netbox/netbox/plugins/navigation.py
  85. 25 6
      netbox/netbox/plugins/registration.py
  86. 2 1
      netbox/netbox/plugins/templates.py
  87. 4 3
      netbox/netbox/registry.py
  88. 2 2
      netbox/netbox/settings.py
  89. 1 2
      netbox/netbox/tests/test_authentication.py
  90. 4 0
      netbox/netbox/tests/test_plugins.py
  91. 10 6
      netbox/netbox/views/generic/bulk_views.py
  92. 4 1
      netbox/netbox/views/generic/object_views.py
  93. 0 0
      netbox/project-static/dist/netbox.css
  94. 0 0
      netbox/project-static/dist/netbox.js
  95. 0 0
      netbox/project-static/dist/netbox.js.map
  96. BIN
      netbox/project-static/img/tint_20.png
  97. 6 5
      netbox/project-static/src/objectSelector.ts
  98. 1 1
      netbox/project-static/src/select/dynamic.ts
  99. 4 2
      netbox/project-static/src/select/static.ts
  100. 0 23
      netbox/project-static/src/util.ts

+ 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

@@ -100,7 +100,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
 
 # Python bindings to the ammonia HTML sanitization library.

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

+ 2 - 2
docs/models/dcim/device.md

@@ -18,9 +18,9 @@ When a device has one or more interfaces with IP addresses assigned, a primary I
 
 The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant.
 
-### Device Role
+### Role
 
-The functional [role](./devicerole.md) assigned to this device.
+The functional [device role](./devicerole.md) assigned to this device.
 
 ### Device Type
 

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

@@ -1,6 +1,41 @@
 # NetBox v3.7
 
-## v3.7.3 (FUTURE)
+## v3.7.4 (FUTURE)
+
+---
+
+## 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
 
 ---
 

+ 8 - 0
docs/release-notes/version-4.0.md

@@ -13,6 +13,10 @@
 
 The NetBox user interface has been completely refreshed and updated.
 
+#### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087))
+
+The REST API now supports specifying which fields to include in the response data.
+
 ### Enhancements
 
 * [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3
@@ -22,6 +26,9 @@ The NetBox user interface has been completely refreshed and updated.
 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12
 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI
 * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI
+* [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects
+* [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets
+* [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations
 
 ### Other Changes
 
@@ -34,5 +41,6 @@ The NetBox user interface has been completely refreshed and updated.
 * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
 * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
 * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
+* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features
 * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
 * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

+ 7 - 1
netbox/circuits/api/serializers.py

@@ -4,8 +4,8 @@ from circuits.choices import CircuitStatusChoices
 from circuits.models import *
 from dcim.api.nested_serializers import NestedSiteSerializer
 from dcim.api.serializers import CabledObjectSerializer
-from ipam.models import ASN
 from ipam.api.nested_serializers import NestedASNSerializer
+from ipam.models import ASN
 from netbox.api.fields import ChoiceField, RelatedObjectCountField, SerializedPKRelatedField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -40,6 +40,7 @@ class ProviderSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'accounts', 'description', 'comments', 'asns', 'tags',
             'custom_fields', 'created', 'last_updated', 'circuit_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
 
 
 #
@@ -56,6 +57,7 @@ class ProviderAccountSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'provider', 'name', 'account', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'account', 'description')
 
 
 #
@@ -72,6 +74,7 @@ class ProviderNetworkSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -90,6 +93,7 @@ class CircuitTypeSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'circuit_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'circuit_count')
 
 
 class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@@ -122,6 +126,7 @@ class CircuitSerializer(NetBoxModelSerializer):
             'termination_date', 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'cid', 'description')
 
 
 class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
@@ -137,3 +142,4 @@ class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer
             'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'circuit', 'term_side', 'description', 'cable', '_occupied')

+ 4 - 0
netbox/circuits/apps.py

@@ -6,4 +6,8 @@ class CircuitsConfig(AppConfig):
     verbose_name = "Circuits"
 
     def ready(self):
+        from netbox.models.features import register_models
         from . import signals, search
+
+        # Register models
+        register_models(*self.get_models())

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

+ 6 - 6
netbox/circuits/tests/test_api.py

@@ -18,7 +18,7 @@ class AppTest(APITestCase):
 
 class ProviderTest(APIViewTestCases.APIViewTestCase):
     model = Provider
-    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
         'comments': 'New comments',
     }
@@ -60,7 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
 
 class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
     model = CircuitType
-    brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['circuit_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = (
         {
             'name': 'Circuit Type 4',
@@ -92,7 +92,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
 
 class CircuitTest(APIViewTestCases.APIViewTestCase):
     model = Circuit
-    brief_fields = ['cid', 'display', 'id', 'url']
+    brief_fields = ['cid', 'description', 'display', 'id', 'url']
     bulk_update_data = {
         'status': 'planned',
     }
@@ -149,7 +149,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
 
 class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
     model = CircuitTermination
-    brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url']
+    brief_fields = ['_occupied', 'cable', 'circuit', 'description', 'display', 'id', 'term_side', 'url']
 
     @classmethod
     def setUpTestData(cls):
@@ -208,7 +208,7 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
 
 class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
     model = ProviderAccount
-    brief_fields = ['account', 'display', 'id', 'name', 'url']
+    brief_fields = ['account', 'description', 'display', 'id', 'name', 'url']
 
     @classmethod
     def setUpTestData(cls):
@@ -251,7 +251,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
 
 class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
     model = ProviderNetwork
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
 
     @classmethod
     def setUpTestData(cls):

+ 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

+ 3 - 0
netbox/core/api/serializers.py

@@ -36,6 +36,7 @@ class DataSourceSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'type', 'source_url', 'enabled', 'status', 'description', 'comments',
             'parameters', 'ignore_rules', 'custom_fields', 'created', 'last_updated', 'file_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class DataFileSerializer(NetBoxModelSerializer):
@@ -51,6 +52,7 @@ class DataFileSerializer(NetBoxModelSerializer):
         fields = [
             'id', 'url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
         ]
+        brief_fields = ('id', 'url', 'display', 'path')
 
 
 class JobSerializer(BaseModelSerializer):
@@ -69,3 +71,4 @@ class JobSerializer(BaseModelSerializer):
             'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
             'started', 'completed', 'user', 'data', 'error', 'job_id',
         ]
+        brief_fields = ('url', 'created', 'completed', 'user', 'status')

+ 5 - 1
netbox/core/apps.py

@@ -16,5 +16,9 @@ class CoreConfig(AppConfig):
     name = "core"
 
     def ready(self):
+        from core.api import schema  # noqa
+        from netbox.models.features import register_models
         from . import data_backends, search
-        from core.api import schema  # noqa: E402
+
+        # Register models
+        register_models(*self.get_models())

+ 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/tests/test_api.py

@@ -16,7 +16,7 @@ class AppTest(APITestCase):
 
 class DataSourceTest(APIViewTestCases.APIViewTestCase):
     model = DataSource
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'enabled': False,
         'description': 'foo bar baz',

+ 1 - 1
netbox/core/views.py

@@ -184,7 +184,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
             )
 
 

+ 57 - 20
netbox/dcim/api/serializers.py

@@ -114,6 +114,7 @@ class RegionSerializer(NestedGroupModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
 class SiteGroupSerializer(NestedGroupModelSerializer):
@@ -127,6 +128,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'site_count', '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth')
 
 
 class SiteSerializer(NetBoxModelSerializer):
@@ -159,6 +161,7 @@ class SiteSerializer(NetBoxModelSerializer):
             'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count',
             'virtualmachine_count', 'vlan_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'slug')
 
 
 #
@@ -180,6 +183,7 @@ class LocationSerializer(NestedGroupModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth')
 
 
 class RackRoleSerializer(NetBoxModelSerializer):
@@ -194,6 +198,7 @@ class RackRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'rack_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
 
 
 class RackSerializer(NetBoxModelSerializer):
@@ -222,6 +227,7 @@ class RackSerializer(NetBoxModelSerializer):
             'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')
 
 
 class RackUnitSerializer(serializers.Serializer):
@@ -256,6 +262,7 @@ class RackReservationSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description',
             'comments', 'tags', 'custom_fields',
         ]
+        brief_fields = ('id', 'url', 'display', 'user', 'description', 'units')
 
 
 class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -315,6 +322,7 @@ class ManufacturerSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
             'devicetype_count', 'inventoryitem_count', 'platform_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'devicetype_count')
 
 
 class DeviceTypeSerializer(NetBoxModelSerializer):
@@ -331,6 +339,8 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
     subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)
     airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False, allow_null=True)
     weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
+    front_image = serializers.URLField(allow_null=True, required=False)
+    rear_image = serializers.URLField(allow_null=True, required=False)
 
     # Counter fields
     console_port_template_count = serializers.IntegerField(read_only=True)
@@ -358,6 +368,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
             'rear_port_template_count', 'device_bay_template_count', 'module_bay_template_count',
             'inventory_item_template_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description', 'device_count')
 
 
 class ModuleTypeSerializer(NetBoxModelSerializer):
@@ -371,6 +382,7 @@ class ModuleTypeSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description',
             'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')
 
 
 #
@@ -401,6 +413,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -427,6 +440,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'description', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -454,6 +468,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw',
             'allocated_draw', 'description', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@@ -491,6 +506,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
             'description', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -535,6 +551,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only',
             'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -557,6 +574,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions',
             'description', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class FrontPortTemplateSerializer(ValidatedModelSerializer):
@@ -580,6 +598,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port',
             'rear_port_position', 'description', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class ModuleBayTemplateSerializer(ValidatedModelSerializer):
@@ -592,6 +611,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class DeviceBayTemplateSerializer(ValidatedModelSerializer):
@@ -601,6 +621,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
     class Meta:
         model = DeviceBayTemplate
         fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 class InventoryItemTemplateSerializer(ValidatedModelSerializer):
@@ -627,6 +648,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
             'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', '_depth')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
@@ -655,6 +677,7 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
 
 
 class PlatformSerializer(NetBoxModelSerializer):
@@ -672,13 +695,13 @@ class PlatformSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'device_count', 'virtualmachine_count')
 
 
 class DeviceSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
     device_type = NestedDeviceTypeSerializer()
     role = NestedDeviceRoleSerializer()
-    device_role = NestedDeviceRoleSerializer(read_only=True, help_text='Deprecated in v3.6 in favor of `role`.')
     tenant = NestedTenantSerializer(required=False, allow_null=True, default=None)
     platform = NestedPlatformSerializer(required=False, allow_null=True)
     site = NestedSiteSerializer()
@@ -720,14 +743,15 @@ class DeviceSerializer(NetBoxModelSerializer):
     class Meta:
         model = Device
         fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags',
-            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
-            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
-            'device_bay_count', 'module_bay_count', 'inventory_item_count',
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields',
+            'created', 'last_updated', 'console_port_count', 'console_server_port_count', 'power_port_count',
+            'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count', 'device_bay_count',
+            'module_bay_count', 'inventory_item_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
     @extend_schema_field(NestedDeviceSerializer)
     def get_parent_device(self, obj):
@@ -740,22 +764,19 @@ class DeviceSerializer(NetBoxModelSerializer):
         data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data
         return data
 
-    def get_device_role(self, obj):
-        return obj.role
-
 
 class DeviceWithConfigContextSerializer(DeviceSerializer):
     config_context = serializers.SerializerMethodField(read_only=True)
 
     class Meta(DeviceSerializer.Meta):
         fields = [
-            'id', 'url', 'display', 'name', 'device_type', 'role', 'device_role', 'tenant', 'platform', 'serial',
-            'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device',
-            'status', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis',
-            'vc_position', 'vc_priority', 'description', 'comments', 'config_template', 'config_context',
-            'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', 'console_port_count',
-            'console_server_port_count', 'power_port_count', 'power_outlet_count', 'interface_count',
-            'front_port_count', 'rear_port_count', 'device_bay_count', 'module_bay_count', 'inventory_item_count',
+            'id', 'url', 'display', 'name', 'device_type', 'role', 'tenant', 'platform', 'serial', 'asset_tag', 'site',
+            'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow',
+            'primary_ip', 'primary_ip4', 'primary_ip6', 'oob_ip', 'cluster', 'virtual_chassis', 'vc_position',
+            'vc_priority', 'description', 'comments', 'config_template', 'config_context', 'local_context_data', 'tags',
+            'custom_fields', 'created', 'last_updated', 'console_port_count', 'console_server_port_count',
+            'power_port_count', 'power_outlet_count', 'interface_count', 'front_port_count', 'rear_port_count',
+            'device_bay_count', 'module_bay_count', 'inventory_item_count',
         ]
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
@@ -782,6 +803,7 @@ class VirtualDeviceContextSerializer(NetBoxModelSerializer):
             'primary_ip6', 'status', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'interface_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'identifier', 'device', 'description')
 
 
 class ModuleSerializer(NetBoxModelSerializer):
@@ -797,6 +819,7 @@ class ModuleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'module_bay', 'module_type', 'description')
 
 
 #
@@ -829,6 +852,7 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer,
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -857,6 +881,7 @@ class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -891,6 +916,7 @@ class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, Conne
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -915,6 +941,7 @@ class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields',
             'created', 'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -977,6 +1004,7 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created',
             'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
     def validate(self, data):
 
@@ -1008,6 +1036,7 @@ class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
             'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created',
             'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class FrontPortRearPortSerializer(WritableNestedSerializer):
@@ -1038,6 +1067,7 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
             'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers',
             'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', 'cable', '_occupied')
 
 
 class ModuleBaySerializer(NetBoxModelSerializer):
@@ -1049,9 +1079,9 @@ class ModuleBaySerializer(NetBoxModelSerializer):
         model = ModuleBay
         fields = [
             'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags',
-            'custom_fields',
-            'created', 'last_updated',
+            'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
 
 
 class DeviceBaySerializer(NetBoxModelSerializer):
@@ -1065,6 +1095,7 @@ class DeviceBaySerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags',
             'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description')
 
 
 class InventoryItemSerializer(NetBoxModelSerializer):
@@ -1088,6 +1119,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
             'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
             'custom_fields', 'created', 'last_updated', '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'device', 'name', 'description', '_depth')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_component(self, obj):
@@ -1114,6 +1146,7 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'inventoryitem_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'inventoryitem_count')
 
 
 #
@@ -1134,6 +1167,7 @@ class CableSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color',
             'length', 'length_unit', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'label', 'description')
 
 
 class TracedCableSerializer(serializers.ModelSerializer):
@@ -1204,6 +1238,7 @@ class VirtualChassisSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'domain', 'master', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'member_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'master', 'description', 'member_count')
 
 
 #
@@ -1228,6 +1263,7 @@ class PowerPanelSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'site', 'location', 'name', 'description', 'comments', 'tags', 'custom_fields',
             'powerfeed_count', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'powerfeed_count')
 
 
 class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer):
@@ -1267,3 +1303,4 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
             'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
             'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'cable', '_occupied')

+ 7 - 5
netbox/dcim/api/views.py

@@ -173,6 +173,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):
         """
@@ -372,12 +378,8 @@ class DeviceViewSet(
 
         Else, return the DeviceWithConfigContextSerializer
         """
-
         request = self.get_serializer_context()['request']
-        if request.query_params.get('brief', False):
-            return serializers.NestedDeviceSerializer
-
-        elif 'config_context' in request.query_params.get('exclude', []):
+        if self.brief or 'config_context' in request.query_params.get('exclude', []):
             return serializers.DeviceSerializer
 
         return serializers.DeviceWithConfigContextSerializer

+ 5 - 1
netbox/dcim/apps.py

@@ -8,9 +8,13 @@ class DCIMConfig(AppConfig):
     verbose_name = "DCIM"
 
     def ready(self):
+        from netbox.models.features import register_models
+        from utilities.counters import connect_counters
         from . import signals, search
         from .models import CableTermination, Device, DeviceType, VirtualChassis
-        from utilities.counters import connect_counters
+
+        # Register models
+        register_models(*self.get_models())
 
         # Register denormalized fields
         denormalized.register(CableTermination, '_device', {

+ 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'),
@@ -447,7 +451,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
@@ -327,17 +328,24 @@ class CableTermination(ChangeLoggedModel):
         existing_termination = qs.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 - 15
netbox/dcim/models/devices.py

@@ -815,20 +815,6 @@ class Device(
     def get_absolute_url(self):
         return reverse('dcim:device', args=[self.pk])
 
-    @property
-    def device_role(self):
-        """
-        For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
-        """
-        return self.role
-
-    @device_role.setter
-    def device_role(self, value):
-        """
-        For backwards compatibility with pre-v3.6 code expecting a device_role to be present on Device.
-        """
-        self.role = value
-
     def clean(self):
         super().clean()
 
@@ -875,7 +861,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

@@ -36,7 +36,7 @@ DEVICEBAY_STATUS = """
 
 INTERFACE_IPADDRESSES = """
   {% 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' %}

+ 42 - 41
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'
@@ -67,7 +68,7 @@ class Mixins:
 
 class RegionTest(APIViewTestCases.APIViewTestCase):
     model = Region
-    brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     create_data = [
         {
             'name': 'Region 4',
@@ -96,7 +97,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase):
 
 class SiteGroupTest(APIViewTestCases.APIViewTestCase):
     model = SiteGroup
-    brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'site_count', 'slug', 'url']
     create_data = [
         {
             'name': 'Site Group 4',
@@ -125,7 +126,7 @@ class SiteGroupTest(APIViewTestCases.APIViewTestCase):
 
 class SiteTest(APIViewTestCases.APIViewTestCase):
     model = Site
-    brief_fields = ['display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     bulk_update_data = {
         'status': 'planned',
     }
@@ -187,7 +188,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
 
 class LocationTest(APIViewTestCases.APIViewTestCase):
     model = Location
-    brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -237,7 +238,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
 
 class RackRoleTest(APIViewTestCases.APIViewTestCase):
     model = RackRole
-    brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'rack_count', 'slug', 'url']
     create_data = [
         {
             'name': 'Rack Role 4',
@@ -272,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
 
 class RackTest(APIViewTestCases.APIViewTestCase):
     model = Rack
-    brief_fields = ['device_count', 'display', 'id', 'name', 'url']
+    brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'status': 'planned',
     }
@@ -360,7 +361,7 @@ class RackTest(APIViewTestCases.APIViewTestCase):
 
 class RackReservationTest(APIViewTestCases.APIViewTestCase):
     model = RackReservation
-    brief_fields = ['display', 'id', 'units', 'url', 'user']
+    brief_fields = ['description', 'display', 'id', 'units', 'url', 'user']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -407,7 +408,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
 
 class ManufacturerTest(APIViewTestCases.APIViewTestCase):
     model = Manufacturer
-    brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['description', 'devicetype_count', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
         {
             'name': 'Manufacturer 4',
@@ -439,7 +440,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
     model = DeviceType
-    brief_fields = ['device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
+    brief_fields = ['description', 'device_count', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
     bulk_update_data = {
         'part_number': 'ABC123',
     }
@@ -484,7 +485,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
     model = ModuleType
-    brief_fields = ['display', 'id', 'manufacturer', 'model', 'url']
+    brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'url']
     bulk_update_data = {
         'part_number': 'ABC123',
     }
@@ -523,7 +524,7 @@ class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
 
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -567,7 +568,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPortTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -611,7 +612,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = PowerPortTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -655,7 +656,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
     model = PowerOutletTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -712,7 +713,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = InterfaceTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -760,7 +761,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = FrontPortTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -849,7 +850,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = RearPortTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -897,7 +898,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ModuleBayTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -937,7 +938,7 @@ class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBayTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -977,7 +978,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
     model = InventoryItemTemplate
-    brief_fields = ['_depth', 'display', 'id', 'name', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1028,7 +1029,7 @@ class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     model = DeviceRole
-    brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+    brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
     create_data = [
         {
             'name': 'Device Role 4',
@@ -1063,7 +1064,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 
 class PlatformTest(APIViewTestCases.APIViewTestCase):
     model = Platform
-    brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+    brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
     create_data = [
         {
             'name': 'Platform 4',
@@ -1095,7 +1096,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceTest(APIViewTestCases.APIViewTestCase):
     model = Device
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'status': 'failed',
     }
@@ -1285,7 +1286,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module
-    brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url']
+    brief_fields = ['description', 'device', 'display', 'id', 'module_bay', 'module_type', 'url']
     bulk_update_data = {
         'serial': '1234ABCD',
     }
@@ -1349,7 +1350,7 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
 
 class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1391,7 +1392,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
 
 class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPort
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1433,7 +1434,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
 
 class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerPort
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1472,7 +1473,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
 
 class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = PowerOutlet
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1520,7 +1521,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
 
 class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = Interface
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1654,7 +1655,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
 
 class FrontPortTest(APIViewTestCases.APIViewTestCase):
     model = FrontPort
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1712,7 +1713,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
 
 class RearPortTest(APIViewTestCases.APIViewTestCase):
     model = RearPort
-    brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1754,7 +1755,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
 
 class ModuleBayTest(APIViewTestCases.APIViewTestCase):
     model = ModuleBay
-    brief_fields = ['display', 'id', 'module', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'installed_module', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1793,7 +1794,7 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
 
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
-    brief_fields = ['device', 'display', 'id', 'name', 'url']
+    brief_fields = ['description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1856,7 +1857,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase):
 
 class InventoryItemTest(APIViewTestCases.APIViewTestCase):
     model = InventoryItem
-    brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url']
+    brief_fields = ['_depth', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1916,7 +1917,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
 
 class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
     model = InventoryItemRole
-    brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
     create_data = [
         {
             'name': 'Inventory Item Role 4',
@@ -1951,7 +1952,7 @@ class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
 
 class CableTest(APIViewTestCases.APIViewTestCase):
     model = Cable
-    brief_fields = ['display', 'id', 'label', 'url']
+    brief_fields = ['description', 'display', 'id', 'label', 'url']
     bulk_update_data = {
         'length': 100,
         'length_unit': 'm',
@@ -2074,7 +2075,7 @@ class ConnectedDeviceTest(APITestCase):
 
 class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
     model = VirtualChassis
-    brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'master', 'member_count', 'name', 'url']
 
     @classmethod
     def setUpTestData(cls):
@@ -2155,7 +2156,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
 
 class PowerPanelTest(APIViewTestCases.APIViewTestCase):
     model = PowerPanel
-    brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'powerfeed_count', 'url']
 
     @classmethod
     def setUpTestData(cls):
@@ -2204,7 +2205,7 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase):
 
 class PowerFeedTest(APIViewTestCases.APIViewTestCase):
     model = PowerFeed
-    brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url']
+    brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'status': 'planned',
     }
@@ -2259,7 +2260,7 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase):
 
 class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
     model = VirtualDeviceContext
-    brief_fields = ['device', 'display', 'id', 'identifier', 'name', 'url']
+    brief_fields = ['description', 'device', 'display', 'id', 'identifier', 'name', 'url']
     bulk_update_data = {
         'status': 'planned',
     }

+ 1 - 1
netbox/dcim/tests/test_cablepaths.py

@@ -2156,7 +2156,7 @@ class CablePathTestCase(TestCase):
         device = Device.objects.create(
             site=self.site,
             device_type=self.device.device_type,
-            device_role=self.device.device_role,
+            role=self.device.role,
             name='Test mid-span Device'
         )
         interface1 = Interface.objects.create(device=self.device, name='Interface 1')

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

+ 0 - 24
netbox/dcim/tests/test_models.py

@@ -533,30 +533,6 @@ class DeviceTestCase(TestCase):
         device2.full_clean()
         device2.save()
 
-    def test_old_device_role_field(self):
-        """
-        Ensure that the old device role field sets the value in the new role field.
-        """
-
-        # Test getter method
-        device = Device(
-            site=Site.objects.first(),
-            device_type=DeviceType.objects.first(),
-            role=DeviceRole.objects.first(),
-            name='Test Device 1',
-            device_role=DeviceRole.objects.first()
-        )
-        device.full_clean()
-        device.save()
-
-        self.assertEqual(device.role, device.device_role)
-
-        # Test setter method
-        device.device_role = DeviceRole.objects.last()
-        device.full_clean()
-        device.save()
-        self.assertEqual(device.role, device.device_role)
-
 
 class CableTestCase(TestCase):
 

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

+ 55 - 68
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
@@ -43,9 +44,6 @@ __all__ = (
     'ImageAttachmentSerializer',
     'JournalEntrySerializer',
     'ObjectChangeSerializer',
-    'ReportDetailSerializer',
-    'ReportSerializer',
-    'ReportInputSerializer',
     'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
@@ -78,15 +76,16 @@ class EventRuleSerializer(NetBoxModelSerializer):
             'type_job_start', 'type_job_end', 'enabled', 'conditions', 'action_type', 'action_object_type',
             'action_object_id', 'action_object', 'description', 'custom_fields', 'tags', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
     @extend_schema_field(OpenApiTypes.OBJECT)
     def get_action_object(self, instance):
         context = {'request': self.context['request']}
         # We need to manually instantiate the serializer for scripts
         if instance.action_type == EventRuleActionChoices.SCRIPT:
-            script_name = instance.action_parameters['script_name']
-            script = instance.action_object.scripts[script_name]()
-            return NestedScriptSerializer(script, context=context).data
+            script = instance.action_object
+            instance = script.python_class() if script.python_class else None
+            return NestedScriptSerializer(instance, context=context).data
         else:
             serializer = get_serializer_for_model(
                 model=instance.action_object_type.model_class(),
@@ -109,6 +108,7 @@ class WebhookSerializer(NetBoxModelSerializer):
             'additional_headers', 'body_template', 'secret', 'ssl_verification', 'ca_file_path', 'custom_fields',
             'tags', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -144,10 +144,11 @@ class CustomFieldSerializer(ValidatedModelSerializer):
             'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choice_set',
             'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
     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
 
@@ -186,6 +187,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
             'choices_count', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count')
 
 
 #
@@ -205,6 +207,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'content_types', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
             'button_class', 'new_window', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name')
 
 
 #
@@ -231,6 +234,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
             'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file', 'data_synced', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -250,6 +254,7 @@ class SavedFilterSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'content_types', 'name', 'slug', 'description', 'user', 'weight', 'enabled',
             'shared', 'parameters', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description')
 
 
 #
@@ -269,6 +274,7 @@ class BookmarkSerializer(ValidatedModelSerializer):
         fields = [
             'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
         ]
+        brief_fields = ('id', 'url', 'display', 'object_id', 'object_type')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_object(self, instance):
@@ -297,6 +303,7 @@ class TagSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'color', 'description')
 
 
 #
@@ -316,6 +323,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
             'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
             'image_width', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'image')
 
     def validate(self, data):
 
@@ -365,6 +373,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
             'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'created')
 
     def validate(self, data):
 
@@ -488,6 +497,7 @@ class ConfigContextSerializer(ValidatedModelSerializer):
             'tenant_groups', 'tenants', 'tags', 'data_source', 'data_path', 'data_file', 'data_synced', 'data',
             'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -509,81 +519,58 @@ class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer
             'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
             'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
         ]
-
-
-#
-# Reports
-#
-
-class ReportSerializer(serializers.Serializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='extras-api:report-detail',
-        lookup_field='full_name',
-        lookup_url_kwarg='pk'
-    )
-    id = serializers.CharField(read_only=True, source="full_name")
-    module = serializers.CharField(max_length=255)
-    name = serializers.CharField(max_length=255)
-    description = serializers.CharField(max_length=255, required=False)
-    test_methods = serializers.ListField(child=serializers.CharField(max_length=255), read_only=True)
-    result = NestedJobSerializer()
-    display = serializers.SerializerMethodField(read_only=True)
-
-    @extend_schema_field(serializers.CharField())
-    def get_display(self, obj):
-        return f'{obj.name} ({obj.module})'
-
-
-class ReportDetailSerializer(ReportSerializer):
-    result = JobSerializer()
-
-
-class ReportInputSerializer(serializers.Serializer):
-    schedule_at = serializers.DateTimeField(required=False, allow_null=True)
-    interval = serializers.IntegerField(required=False, allow_null=True)
-
-    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.")
-        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.")
-        return value
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
 # Scripts
 #
 
-class ScriptSerializer(serializers.Serializer):
-    url = serializers.HyperlinkedIdentityField(
-        view_name='extras-api:script-detail',
-        lookup_field='full_name',
-        lookup_url_kwarg='pk'
-    )
-    id = serializers.CharField(read_only=True, source="full_name")
-    module = serializers.CharField(max_length=255)
-    name = serializers.CharField(read_only=True)
-    description = serializers.CharField(read_only=True)
+class ScriptSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='extras-api:script-detail')
+    description = serializers.SerializerMethodField(read_only=True)
     vars = serializers.SerializerMethodField(read_only=True)
-    result = NestedJobSerializer()
-    display = serializers.SerializerMethodField(read_only=True)
+    result = NestedJobSerializer(read_only=True)
+
+    class Meta:
+        model = Script
+        fields = [
+            'id', 'url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
+        ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
-    def get_vars(self, instance):
-        return {
-            k: v.__class__.__name__ for k, v in instance._get_vars().items()
-        }
+    def get_vars(self, obj):
+        if obj.python_class:
+            return {
+                k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
+            }
+        else:
+            return {}
 
     @extend_schema_field(serializers.CharField())
     def get_display(self, obj):
         return f'{obj.name} ({obj.module})'
 
+    @extend_schema_field(serializers.CharField())
+    def get_description(self, obj):
+        if obj.python_class:
+            return obj.python_class().description
+        else:
+            return None
+
 
 class ScriptDetailSerializer(ScriptSerializer):
-    result = JobSerializer()
+    result = serializers.SerializerMethodField(read_only=True)
+
+    @extend_schema_field(JobSerializer())
+    def get_result(self, obj):
+        job = obj.jobs.all().order_by('-created').first()
+        context = {
+            'request': self.context['request']
+        }
+        data = JobSerializer(job, context=context).data
+        return data
 
 
 class ScriptInputSerializer(serializers.Serializer):
@@ -594,12 +581,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
 
 

+ 13 - 51
netbox/extras/api/views.py

@@ -1,5 +1,4 @@
 from django.contrib.contenttypes.models import ContentType
-from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django_rq.queues import get_connection
 from rest_framework import status
@@ -9,14 +8,13 @@ from rest_framework.generics import RetrieveUpdateDestroyAPIView
 from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
+from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
 from rq import Worker
 
-from core.choices import JobStatusChoices
 from core.models import Job
 from extras import filtersets
 from extras.models import *
-from extras.scripts import get_module_and_script, run_script
+from extras.scripts import run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
@@ -209,66 +207,30 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
 # Scripts
 #
 
-class ScriptViewSet(ViewSet):
+class ScriptViewSet(ModelViewSet):
     permission_classes = [IsAuthenticatedOrLoginNotRequired]
+    queryset = Script.objects.prefetch_related('jobs')
+    serializer_class = serializers.ScriptSerializer
+    filterset_class = filtersets.ScriptFilterSet
+
     _ignore_model_permissions = True
-    schema = None
     lookup_value_regex = '[^/]+'  # Allow dots
 
-    def _get_script(self, pk):
-        try:
-            module_name, script_name = pk.split('.', maxsplit=1)
-        except ValueError:
-            raise Http404
-
-        module, script = get_module_and_script(module_name, script_name)
-        if script is None:
-            raise Http404
-
-        return module, script
-
-    def list(self, request):
-        results = {
-            job.name: job
-            for job in Job.objects.filter(
-                object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
-                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-            ).order_by('name', '-created').distinct('name').defer('data')
-        }
-
-        script_list = []
-        for script_module in ScriptModule.objects.restrict(request.user):
-            script_list.extend(script_module.scripts.values())
-
-        # Attach Job objects to each script (if any)
-        for script in script_list:
-            script.result = results.get(script.class_name, None)
-
-        serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
-
-        return Response({'count': len(script_list), 'results': serializer.data})
-
     def retrieve(self, request, pk):
-        module, script = self._get_script(pk)
-        object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
-        script.result = Job.objects.filter(
-            object_type=object_type,
-            name=script.class_name,
-            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-        ).first()
+        script = get_object_or_404(self.queryset, pk=pk)
         serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
 
         return Response(serializer.data)
 
     def post(self, request, pk):
         """
-        Run a Script identified as "<module>.<script>" and return the pending Job as the result
+        Run a Script identified by the id and return the pending Job as the result
         """
 
         if not request.user.has_perm('extras.run_script'):
             raise PermissionDenied("This user does not have permission to run scripts.")
 
-        module, script = self._get_script(pk)
+        script = get_object_or_404(self.queryset, pk=pk)
         input_serializer = serializers.ScriptInputSerializer(
             data=request.data,
             context={'script': script}
@@ -281,13 +243,13 @@ class ScriptViewSet(ViewSet):
         if input_serializer.is_valid():
             script.result = Job.enqueue(
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.python_class.class_name,
                 user=request.user,
                 data=input_serializer.data['data'],
                 request=copy_safe_request(request),
                 commit=input_serializer.data['commit'],
-                job_timeout=script.job_timeout,
+                job_timeout=script.python_class.job_timeout,
                 schedule_at=input_serializer.validated_data.get('schedule_at'),
                 interval=input_serializer.validated_data.get('interval')
             )

+ 4 - 0
netbox/extras/apps.py

@@ -5,4 +5,8 @@ class ExtrasConfig(AppConfig):
     name = "extras"
 
     def ready(self):
+        from netbox.models.features import register_models
         from . import dashboard, lookups, search, signals
+
+        # Register models
+        register_models(*self.get_models())

+ 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

@@ -111,7 +111,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):
@@ -177,7 +179,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):
@@ -231,7 +233,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):

+ 8 - 7
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
@@ -115,21 +116,21 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
         # Scripts
         elif event_rule.action_type == EventRuleActionChoices.SCRIPT:
             # Resolve the script from action parameters
-            script_module = event_rule.action_object
-            script_name = event_rule.action_parameters['script_name']
-            script = script_module.scripts[script_name]()
+            script = event_rule.action_object.python_class()
 
             # Enqueue a Job to record the script's execution
             Job.enqueue(
                 "extras.scripts.run_script",
-                instance=script_module,
-                name=script.class_name,
+                instance=script.module,
+                name=script.name,
                 user=user,
                 data=data
             )
 
         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 +176,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))

+ 21 - 0
netbox/extras/filtersets.py

@@ -29,11 +29,32 @@ __all__ = (
     'LocalConfigContextFilterSet',
     'ObjectChangeFilterSet',
     'SavedFilterFilterSet',
+    'ScriptFilterSet',
     'TagFilterSet',
     'WebhookFilterSet',
 )
 
 
+class ScriptFilterSet(BaseFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+
+    class Meta:
+        model = Script
+        fields = [
+            'id', 'name',
+        ]
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value)
+        )
+
+
 class WebhookFilterSet(NetBoxModelFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 4 - 7
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,12 +211,9 @@ 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")
-                self.instance.action_object = module
-                self.instance.action_object_type = ContentType.objects.get_for_model(module, for_concrete_model=False)
-                self.instance.action_parameters = {
-                    'script_name': script_name,
-                }
+                    raise forms.ValidationError(_("Script {name} not found").format(name=action_object))
+                self.instance.action_object = script
+                self.instance.action_object_type = ContentType.objects.get_for_model(script, for_concrete_model=False)
 
 
 class TagImportForm(CSVModelForm):

+ 12 - 29
netbox/extras/forms/model_forms.py

@@ -297,20 +297,16 @@ class EventRuleForm(NetBoxModelForm):
         }
 
     def init_script_choice(self):
-        choices = []
-        for module in ScriptModule.objects.all():
-            scripts = []
-            for script_name in module.scripts.keys():
-                name = f"{str(module.pk)}:{script_name}"
-                scripts.append((name, script_name))
-            if scripts:
-                choices.append((str(module), scripts))
-        self.fields['action_choice'].choices = choices
-
-        if self.instance.action_type == EventRuleActionChoices.SCRIPT and self.instance.action_parameters:
-            scriptmodule_id = self.instance.action_object_id
-            script_name = self.instance.action_parameters.get('script_name')
-            self.fields['action_choice'].initial = f'{scriptmodule_id}:{script_name}'
+        initial = None
+        if self.instance.action_type == EventRuleActionChoices.SCRIPT:
+            script_id = get_field_value(self, 'action_object_id')
+            initial = Script.objects.get(pk=script_id) if script_id else None
+        self.fields['action_choice'] = DynamicModelChoiceField(
+            label=_('Script'),
+            queryset=Script.objects.all(),
+            required=True,
+            initial=initial
+        )
 
     def init_webhook_choice(self):
         initial = None
@@ -348,26 +344,13 @@ class EventRuleForm(NetBoxModelForm):
         # Script
         elif self.cleaned_data.get('action_type') == EventRuleActionChoices.SCRIPT:
             self.cleaned_data['action_object_type'] = ContentType.objects.get_for_model(
-                ScriptModule,
+                Script,
                 for_concrete_model=False
             )
-            module_id, script_name = action_choice.split(":", maxsplit=1)
-            self.cleaned_data['action_object_id'] = module_id
+            self.cleaned_data['action_object_id'] = action_choice.id
 
         return self.cleaned_data
 
-    def save(self, *args, **kwargs):
-        # Set action_parameters on the instance
-        if self.cleaned_data['action_type'] == EventRuleActionChoices.SCRIPT:
-            module_id, script_name = self.cleaned_data.get('action_choice').split(":", maxsplit=1)
-            self.instance.action_parameters = {
-                'script_name': script_name,
-            }
-        else:
-            self.instance.action_parameters = None
-
-        return super().save(*args, **kwargs)
-
 
 class TagForm(forms.ModelForm):
     slug = SlugField()

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

+ 1 - 1
netbox/extras/migrations/0107_convert_reports_to_scripts.py → netbox/extras/migrations/0108_convert_reports_to_scripts.py

@@ -14,7 +14,7 @@ def convert_reportmodule_jobs(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('extras', '0106_bookmark_user_cascade_deletion'),
+        ('extras', '0107_cachedvalue_extras_cachedvalue_object'),
     ]
 
     operations = [

+ 159 - 0
netbox/extras/migrations/0109_script_model.py

@@ -0,0 +1,159 @@
+import inspect
+import os
+from importlib.machinery import SourceFileLoader
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+#
+# Note: This has a couple dependencies on the codebase if doing future modifications:
+# There are imports from extras.scripts and extras.reports as well as expecting
+# settings.SCRIPTS_ROOT and settings.REPORTS_ROOT to be in settings
+#
+
+ROOT_PATHS = {
+    'scripts': settings.SCRIPTS_ROOT,
+    'reports': settings.REPORTS_ROOT,
+}
+
+
+def get_full_path(scriptmodule):
+    """
+    Return the full path to a ScriptModule's file on disk.
+    """
+    root_path = ROOT_PATHS[scriptmodule.file_root]
+    return os.path.join(root_path, scriptmodule.file_path)
+
+
+def get_python_name(scriptmodule):
+    """
+    Return the Python name of a ScriptModule's file on disk.
+    """
+    path, filename = os.path.split(scriptmodule.file_path)
+    return os.path.splitext(filename)[0]
+
+
+def is_script(obj):
+    """
+    Returns True if the passed Python object is a Script or Report.
+    """
+    from extras.scripts import Script
+    from extras.reports import Report
+
+    try:
+        if issubclass(obj, Report) and obj != Report:
+            return True
+        if issubclass(obj, Script) and obj != Script:
+            return True
+    except TypeError:
+        pass
+    return False
+
+
+def get_module_scripts(scriptmodule):
+    """
+    Return a dictionary mapping of name and script class inside the passed ScriptModule.
+    """
+    def get_name(cls):
+        # For child objects in submodules use the full import path w/o the root module as the name
+        return cls.full_name.split(".", maxsplit=1)[1]
+
+    loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
+    module = loader.load_module()
+
+    scripts = {}
+    ordered = getattr(module, 'script_order', [])
+
+    for cls in ordered:
+        scripts[get_name(cls)] = cls
+    for name, cls in inspect.getmembers(module, is_script):
+        if cls not in ordered:
+            scripts[get_name(cls)] = cls
+
+    return scripts
+
+
+def update_scripts(apps, schema_editor):
+    """
+    Create a new Script object for each script inside each existing ScriptModule, and update any related jobs to
+    reference the new Script object.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Script = apps.get_model('extras', 'Script')
+    ScriptModule = apps.get_model('extras', 'ScriptModule')
+    Job = apps.get_model('core', 'Job')
+
+    script_ct = ContentType.objects.get_for_model(Script)
+    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
+
+    for module in ScriptModule.objects.all():
+        for script_name in get_module_scripts(module):
+            script = Script.objects.create(
+                name=script_name,
+                module=module,
+            )
+
+            # Update all Jobs associated with this ScriptModule & script name to point to the new Script object
+            Job.objects.filter(
+                object_type=scriptmodule_ct,
+                object_id=module.pk,
+                name=script_name
+            ).update(object_type=script_ct, object_id=script.pk)
+
+
+def update_event_rules(apps, schema_editor):
+    """
+    Update any existing EventRules for scripts. Change action_object_type from ScriptModule to Script, and populate
+    the ID of the related Script object.
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Script = apps.get_model('extras', 'Script')
+    ScriptModule = apps.get_model('extras', 'ScriptModule')
+    EventRule = apps.get_model('extras', 'EventRule')
+
+    script_ct = ContentType.objects.get_for_model(Script)
+    scriptmodule_ct = ContentType.objects.get_for_model(ScriptModule)
+
+    for eventrule in EventRule.objects.filter(action_object_type=scriptmodule_ct):
+        name = eventrule.action_parameters.get('script_name')
+        obj, created = Script.objects.get_or_create(
+            module_id=eventrule.action_object_id,
+            name=name,
+            defaults={'is_executable': False}
+        )
+        EventRule.objects.filter(pk=eventrule.pk).update(action_object_type=script_ct, action_object_id=obj.id)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0108_convert_reports_to_scripts'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Script',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(editable=False, max_length=79)),
+                ('module', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='scripts', to='extras.scriptmodule')),
+                ('is_executable', models.BooleanField(editable=False, default=True))
+            ],
+            options={
+                'ordering': ('module', 'name'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='script',
+            constraint=models.UniqueConstraint(fields=('name', 'module'), name='extras_script_unique_name_module'),
+        ),
+        migrations.RunPython(
+            code=update_scripts,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.RunPython(
+            code=update_event_rules,
+            reverse_code=migrations.RunPython.noop
+        ),
+    ]

+ 15 - 0
netbox/extras/migrations/0110_remove_eventrule_action_parameters.py

@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0109_script_model'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='eventrule',
+            name='action_parameters',
+        ),
+    ]

+ 0 - 4
netbox/extras/models/models.py

@@ -115,10 +115,6 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
         ct_field='action_object_type',
         fk_field='action_object_id'
     )
-    action_parameters = models.JSONField(
-        blank=True,
-        null=True
-    )
     action_data = models.JSONField(
         verbose_name=_('data'),
         blank=True,

+ 93 - 6
netbox/extras/models/scripts.py

@@ -2,8 +2,11 @@ import inspect
 import logging
 from functools import cached_property
 
+from django.contrib.contenttypes.fields import GenericRelation
 from django.db import models
 from django.db.models import Q
+from django.db.models.signals import post_save
+from django.dispatch import receiver
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
@@ -22,12 +25,63 @@ __all__ = (
 logger = logging.getLogger('netbox.data_backends')
 
 
-class Script(EventRulesMixin, models.Model):
-    """
-    Dummy model used to generate permissions for custom scripts. Does not exist in the database.
-    """
+class Script(EventRulesMixin, JobsMixin):
+    name = models.CharField(
+        verbose_name=_('name'),
+        max_length=79,  # Maximum length for a Python class name
+        editable=False,
+    )
+    module = models.ForeignKey(
+        to='extras.ScriptModule',
+        on_delete=models.CASCADE,
+        related_name='scripts',
+        editable=False
+    )
+    is_executable = models.BooleanField(
+        default=True,
+        verbose_name=_('is executable'),
+        editable=False
+    )
+    events = GenericRelation(
+        'extras.EventRule',
+        content_type_field='action_object_type',
+        object_id_field='action_object_id'
+    )
+
+    def __str__(self):
+        return self.name
+
+    objects = RestrictedQuerySet.as_manager()
+
     class Meta:
-        managed = False
+        ordering = ('module', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('name', 'module'),
+                name='extras_script_unique_name_module'
+            ),
+        )
+        verbose_name = _('script')
+        verbose_name_plural = _('scripts')
+
+    def get_absolute_url(self):
+        return reverse('extras:script', args=[self.pk])
+
+    @property
+    def result(self):
+        return self.jobs.all().order_by('-created').first()
+
+    @cached_property
+    def python_class(self):
+        return self.module.module_scripts.get(self.name)
+
+    def delete(self, soft_delete=False, **kwargs):
+        if soft_delete and self.jobs.exists():
+            self.is_executable = False
+            self.save()
+        else:
+            super().delete(**kwargs)
+            self.id = None
 
 
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
@@ -55,7 +109,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
         return self.python_name
 
     @cached_property
-    def scripts(self):
+    def module_scripts(self):
 
         def _get_name(cls):
             # For child objects in submodules use the full import path w/o the root module as the name
@@ -78,6 +132,39 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
 
         return scripts
 
+    def sync_classes(self):
+        """
+        Syncs the file-based module to the database, adding and removing individual Script objects
+        in the database as needed.
+        """
+        db_classes = {
+            script.name: script for script in self.scripts.all()
+        }
+        db_classes_set = set(db_classes.keys())
+        module_classes_set = set(self.module_scripts.keys())
+
+        # remove any existing db classes if they are no longer in the file
+        removed = db_classes_set - module_classes_set
+        for name in removed:
+            db_classes[name].delete(soft_delete=True)
+
+        added = module_classes_set - db_classes_set
+        for name in added:
+            Script.objects.create(
+                module=self,
+                name=name,
+                is_executable=True,
+            )
+
+    def sync_data(self):
+        super().sync_data()
+        self.sync_classes()
+
     def save(self, *args, **kwargs):
         self.file_root = ManagedFileRootPathChoices.SCRIPTS
         return super().save(*args, **kwargs)
+
+
+@receiver(post_save, sender=ScriptModule)
+def script_module_post_save_handler(instance, created, **kwargs):
+    instance.sync_classes()

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

+ 1 - 0
netbox/extras/reports.py

@@ -6,6 +6,7 @@ __all__ = (
 )
 
 
+# Required by extras/migrations/0109_script_models.py
 class Report(BaseScript):
 
     #

+ 5 - 6
netbox/extras/scripts.py

@@ -17,7 +17,7 @@ from django.utils.translation import gettext as _
 from core.choices import JobStatusChoices
 from core.models import Job
 from extras.choices import LogLevelChoices
-from extras.models import ScriptModule
+from extras.models import ScriptModule, Script as ScriptModel
 from extras.signals import clear_events
 from ipam.formfields import IPAddressFormField, IPNetworkFormField
 from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
@@ -411,11 +411,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
 
@@ -582,7 +582,7 @@ def is_variable(obj):
 
 def get_module_and_script(module_name, script_name):
     module = ScriptModule.objects.get(file_path=f'{module_name}.py')
-    script = module.scripts.get(script_name)
+    script = module.scripts.get(name=script_name)
     return module, script
 
 
@@ -599,8 +599,7 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     """
     job.start()
 
-    module = ScriptModule.objects.get(pk=job.object_id)
-    script = module.scripts.get(job.name)()
+    script = ScriptModel.objects.get(pk=job.object_id).python_class()
 
     logger = logging.getLogger(f"netbox.scripts.{script.full_name}")
     logger.info(f"Running script (commit={commit})")

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

+ 28 - 19
netbox/extras/tests/test_api.py

@@ -11,7 +11,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
 from extras.choices import *
 from extras.models import *
 from extras.reports import Report
-from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
+from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from utilities.testing import APITestCase, APIViewTestCases
 
 User = get_user_model()
@@ -29,7 +29,7 @@ class AppTest(APITestCase):
 
 class WebhookTest(APIViewTestCases.APIViewTestCase):
     model = Webhook
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'name': 'Webhook 4',
@@ -71,7 +71,7 @@ class WebhookTest(APIViewTestCases.APIViewTestCase):
 
 class EventRuleTest(APIViewTestCases.APIViewTestCase):
     model = EventRule
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'enabled': False,
         'description': 'New description',
@@ -149,7 +149,7 @@ class EventRuleTest(APIViewTestCases.APIViewTestCase):
 
 class CustomFieldTest(APIViewTestCases.APIViewTestCase):
     model = CustomField
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'content_types': ['dcim.site'],
@@ -201,7 +201,7 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase):
 
 class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
     model = CustomFieldChoiceSet
-    brief_fields = ['choices_count', 'display', 'id', 'name', 'url']
+    brief_fields = ['choices_count', 'description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'name': 'Choice Set 4',
@@ -330,7 +330,7 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
 
 class SavedFilterTest(APIViewTestCases.APIViewTestCase):
     model = SavedFilter
-    brief_fields = ['display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
         {
             'content_types': ['dcim.site'],
@@ -455,7 +455,7 @@ class BookmarkTest(
 
 class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ExportTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'content_types': ['dcim.device'],
@@ -500,7 +500,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class TagTest(APIViewTestCases.APIViewTestCase):
     model = Tag
-    brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['color', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
         {
             'name': 'Tag 4',
@@ -627,7 +627,7 @@ class JournalEntryTest(APIViewTestCases.APIViewTestCase):
 
 class ConfigContextTest(APIViewTestCases.APIViewTestCase):
     model = ConfigContext
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'name': 'Config Context 4',
@@ -708,7 +708,7 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
 
 class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConfigTemplate
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'name': 'Config Template 4',
@@ -748,7 +748,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class ScriptTest(APITestCase):
 
-    class TestScript(Script):
+    class TestScriptClass(PythonClass):
 
         class Meta:
             name = "Test script"
@@ -767,27 +767,36 @@ class ScriptTest(APITestCase):
 
     @classmethod
     def setUpTestData(cls):
-        ScriptModule.objects.create(
+        module = ScriptModule.objects.create(
             file_root=ManagedFileRootPathChoices.SCRIPTS,
             file_path='/var/tmp/script.py'
         )
+        Script.objects.create(
+            module=module,
+            name="Test script",
+            is_executable=True,
+        )
 
-    def get_test_script(self, *args):
-        return ScriptModule.objects.first(), self.TestScript
+    def python_class(self):
+        return self.TestScriptClass
 
     def setUp(self):
         super().setUp()
 
-        # Monkey-patch the API viewset's _get_script() method to return our test Script above
+        # Monkey-patch the Script model to return our TestScriptClass above
         from extras.api.views import ScriptViewSet
-        ScriptViewSet._get_script = self.get_test_script
+        Script.python_class = self.python_class
 
     def test_get_script(self):
-
-        url = reverse('extras-api:script-detail', kwargs={'pk': None})
+        module = ScriptModule.objects.get(
+            file_root=ManagedFileRootPathChoices.SCRIPTS,
+            file_path='/var/tmp/script.py'
+        )
+        script = module.scripts.all().first()
+        url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
         response = self.client.get(url, **self.header)
 
-        self.assertEqual(response.data['name'], self.TestScript.Meta.name)
+        self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
         self.assertEqual(response.data['vars']['var1'], 'StringVar')
         self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
         self.assertEqual(response.data['vars']['var3'], 'BooleanVar')

+ 9 - 4
netbox/extras/urls.py

@@ -120,10 +120,15 @@ urlpatterns = [
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
     path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
-    path('scripts/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
-    path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
-    path('scripts/<str:module>/<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
-    path('scripts/<str:module>/<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
+    path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
+    path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
+    path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
+    path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
+
+    # Redirects for legacy script URLs
+    # TODO: Remove in NetBox v4.1
+    path('scripts/<str:module>/<str:name>/', views.LegacyScriptRedirectView.as_view()),
+    path('scripts/<str:module>/<str:name>/<path:path>/', views.LegacyScriptRedirectView.as_view()),
 
     # Markdown
     path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"),

+ 0 - 20
netbox/extras/utils.py

@@ -1,7 +1,5 @@
 from taggit.managers import _TaggableManager
 
-from netbox.registry import registry
-
 
 def is_taggable(obj):
     """
@@ -29,24 +27,6 @@ def image_upload(instance, filename):
     return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
 
 
-def register_features(model, features):
-    """
-    Register model features in the application registry.
-    """
-    app_label, model_name = model._meta.label_lower.split('.')
-    for feature in features:
-        try:
-            registry['model_features'][feature][app_label].add(model_name)
-        except KeyError:
-            raise KeyError(
-                f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
-            )
-
-    # Register public models
-    if not getattr(model, '_netbox_private', False):
-        registry['models'][app_label].add(model_name)
-
-
 def is_script(obj):
     """
     Returns True if the object is a Script or Report.

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

+ 61 - 62
netbox/extras/views.py

@@ -920,7 +920,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
                 widget = widget_class(**data)
                 request.user.dashboard.add_widget(widget)
                 request.user.dashboard.save()
-                messages.success(request, f'Added widget {widget.id}')
+                messages.success(request, _('Added widget: ') + str(widget.id))
 
                 return HttpResponse(headers={
                     'HX-Redirect': reverse('home'),
@@ -961,7 +961,7 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
             data['config'] = config_form.cleaned_data
             request.user.dashboard.config[str(id)].update(data)
             request.user.dashboard.save()
-            messages.success(request, f'Updated widget {widget.id}')
+            messages.success(request, _('Updated widget: ') + str(widget.id))
 
             return HttpResponse(headers={
                 'HX-Redirect': reverse('home'),
@@ -997,9 +997,9 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
         if form.is_valid():
             request.user.dashboard.delete_widget(id)
             request.user.dashboard.save()
-            messages.success(request, f'Deleted widget {id}')
+            messages.success(request, _('Deleted widget: ') + str(id))
         else:
-            messages.error(request, f'Error deleting widget: {form.errors[0]}')
+            messages.error(request, _('Error deleting widget: ') + str(form.errors[0]))
 
         return redirect(reverse('home'))
 
@@ -1030,7 +1030,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
 
     def get(self, request):
-        script_modules = ScriptModule.objects.restrict(request.user)
+        script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
 
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,
@@ -1038,123 +1038,122 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         })
 
 
-def get_script_module(module, request):
-    return get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+class ScriptView(generic.ObjectView):
+    queryset = Script.objects.all()
 
-
-class ScriptView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_script'
-
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
-        form = script.as_form(initial=normalize_querydict(request.GET))
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
+        script_class = script.python_class()
+        form = script_class.as_form(initial=normalize_querydict(request.GET))
 
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
+            'script_class': script_class,
             'form': form,
+            'job_count': script.jobs.count(),
         })
 
-    def post(self, request, module, name):
-        if not request.user.has_perm('extras.run_script'):
+    def post(self, request, **kwargs):
+        script = self.get_object(**kwargs)
+        script_class = script.python_class()
+
+        if not request.user.has_perm('extras.run_script', obj=script):
             return HttpResponseForbidden()
 
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
-        form = script.as_form(request.POST, request.FILES)
+        form = script_class.as_form(request.POST, request.FILES)
 
         # Allow execution only if RQ worker process is running
         if not get_workers_for_queue('default'):
-            messages.error(request, "Unable to run script: RQ worker process not running.")
-
+            messages.error(request, _("Unable to run script: RQ worker process not running."))
         elif form.is_valid():
             job = Job.enqueue(
                 run_script,
-                instance=module,
-                name=script.class_name,
+                instance=script,
+                name=script_class.class_name,
                 user=request.user,
                 schedule_at=form.cleaned_data.pop('_schedule_at'),
                 interval=form.cleaned_data.pop('_interval'),
                 data=form.cleaned_data,
                 request=copy_safe_request(request),
-                job_timeout=script.job_timeout,
+                job_timeout=script.python_class.job_timeout,
                 commit=form.cleaned_data.pop('_commit')
             )
 
             return redirect('extras:script_result', job_pk=job.pk)
 
         return render(request, 'extras/script.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
+            'script_class': script.python_class(),
             'form': form,
+            'job_count': script.jobs.count(),
         })
 
 
-class ScriptSourceView(ContentTypePermissionRequiredMixin, View):
+class ScriptSourceView(generic.ObjectView):
+    queryset = Script.objects.all()
 
-    def get_required_permission(self):
-        return 'extras.view_script'
-
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
 
         return render(request, 'extras/script/source.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
+            'script_class': script.python_class(),
+            'job_count': script.jobs.count(),
             'tab': 'source',
         })
 
 
-class ScriptJobsView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_script'
+class ScriptJobsView(generic.ObjectView):
+    queryset = Script.objects.all()
 
-    def get(self, request, module, name):
-        module = get_script_module(module, request)
-        script = module.scripts[name]()
-        jobs = module.get_jobs(script.class_name)
+    def get(self, request, **kwargs):
+        script = self.get_object(**kwargs)
 
         jobs_table = JobTable(
-            data=jobs,
+            data=script.jobs.all(),
             orderable=False,
             user=request.user
         )
         jobs_table.configure(request)
 
         return render(request, 'extras/script/jobs.html', {
-            'job_count': jobs.count(),
-            'module': module,
             'script': script,
             'table': jobs_table,
+            'job_count': script.jobs.count(),
             'tab': 'jobs',
         })
 
 
-class ScriptResultView(ContentTypePermissionRequiredMixin, View):
-
+class LegacyScriptRedirectView(ContentTypePermissionRequiredMixin, View):
+    """
+    Redirect legacy (pre-v4.0) script URLs. Examples:
+        /extras/scripts/<module>/<name>/         -->  /extras/scripts/<id>/
+        /extras/scripts/<module>/<name>/source/  -->  /extras/scripts/<id>/source/
+        /extras/scripts/<module>/<name>/jobs/    -->  /extras/scripts/<id>/jobs/
+    """
     def get_required_permission(self):
         return 'extras.view_script'
 
-    def get(self, request, job_pk):
-        object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='scriptmodule')
-        job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
+    def get(self, request, module, name, path=''):
+        module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
+        script = get_object_or_404(Script.objects.all(), module=module, name=name)
+
+        url = reverse('extras:script', kwargs={'pk': script.pk})
+
+        return redirect(f'{url}{path}')
+
 
-        module = job.object
-        script = module.scripts[job.name]()
+class ScriptResultView(generic.ObjectView):
+    queryset = Job.objects.all()
+
+    def get_required_permission(self):
+        return 'extras.view_script'
+
+    def get(self, request, **kwargs):
+        job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
 
         context = {
-            'script': script,
+            'script': job.object,
             'job': job,
         }
         if job.data and 'log' in job.data:

+ 18 - 2
netbox/ipam/api/serializers.py

@@ -33,6 +33,7 @@ class ASNRangeSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'rir', 'start', 'end', 'tenant', 'description', 'tags',
             'custom_fields', 'created', 'last_updated', 'asn_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -54,6 +55,7 @@ class ASNSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'asn', 'rir', 'tenant', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated', 'site_count', 'provider_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'asn', 'description')
 
 
 class AvailableASNSerializer(serializers.Serializer):
@@ -104,6 +106,7 @@ class VRFSerializer(NetBoxModelSerializer):
             'import_targets', 'export_targets', 'tags', 'custom_fields', 'created', 'last_updated', 'ipaddress_count',
             'prefix_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'rd', 'description', 'prefix_count')
 
 
 #
@@ -120,6 +123,7 @@ class RouteTargetSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'tenant', 'description', 'comments', 'tags', 'custom_fields', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'description')
 
 
 #
@@ -138,6 +142,7 @@ class RIRSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'aggregate_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'aggregate_count')
 
 
 class AggregateSerializer(NetBoxModelSerializer):
@@ -153,6 +158,7 @@ class AggregateSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description')
 
 
 #
@@ -169,6 +175,7 @@ class FHRPGroupSerializer(NetBoxModelSerializer):
             'id', 'name', 'url', 'display', 'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated', 'ip_addresses',
         ]
+        brief_fields = ('id', 'url', 'display', 'protocol', 'group_id', 'description')
 
 
 class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
@@ -185,6 +192,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'interface', 'priority', 'created',
             'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'group', 'interface_type', 'interface_id', 'priority')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_interface(self, obj):
@@ -212,6 +220,7 @@ class RoleSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created',
             'last_updated', 'prefix_count', 'vlan_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'prefix_count', 'vlan_count')
 
 
 class VLANGroupSerializer(NetBoxModelSerializer):
@@ -237,6 +246,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid', 'max_vid',
             'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
         validators = []
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
@@ -267,6 +277,7 @@ class VLANSerializer(NetBoxModelSerializer):
             'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description',
             'comments', 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count',
         ]
+        brief_fields = ('id', 'url', 'display', 'vid', 'name', 'description')
 
 
 class AvailableVLANSerializer(serializers.Serializer):
@@ -327,6 +338,7 @@ class PrefixSerializer(NetBoxModelSerializer):
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'children',
             '_depth',
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth')
 
 
 class PrefixLengthSerializer(serializers.Serializer):
@@ -397,6 +409,7 @@ class IPRangeSerializer(NetBoxModelSerializer):
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
             'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'start_address', 'end_address', 'description')
 
 
 #
@@ -427,6 +440,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
             'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'comments',
             'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'family', 'address', 'description')
 
     @extend_schema_field(serializers.JSONField(allow_null=True))
     def get_assigned_object(self, obj):
@@ -469,9 +483,10 @@ class ServiceTemplateSerializer(NetBoxModelSerializer):
     class Meta:
         model = ServiceTemplate
         fields = [
-            'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'comments', 'tags', 'custom_fields',
+            'id', 'url', 'display', 'name', 'protocol', 'ports', 'description', 'comments', 'tags', 'custom_fields',
             'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')
 
 
 class ServiceSerializer(NetBoxModelSerializer):
@@ -489,6 +504,7 @@ class ServiceSerializer(NetBoxModelSerializer):
     class Meta:
         model = Service
         fields = [
-            'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses',
+            'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses',
             'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
+        brief_fields = ('id', 'url', 'display', 'name', 'protocol', 'ports', 'description')

+ 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
@@ -354,7 +355,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
 

+ 4 - 0
netbox/ipam/apps.py

@@ -6,4 +6,8 @@ class IPAMConfig(AppConfig):
     verbose_name = "IPAM"
 
     def ready(self):
+        from netbox.models.features import register_models
         from . import signals, search
+
+        # Register models
+        register_models(*self.get_models())

+ 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

@@ -756,4 +756,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."))

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

@@ -23,7 +23,7 @@ class AppTest(APITestCase):
 
 class ASNRangeTest(APIViewTestCases.APIViewTestCase):
     model = ASNRange
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -135,7 +135,7 @@ class ASNRangeTest(APIViewTestCases.APIViewTestCase):
 
 class ASNTest(APIViewTestCases.APIViewTestCase):
     model = ASN
-    brief_fields = ['asn', 'display', 'id', 'url']
+    brief_fields = ['asn', 'description', 'display', 'id', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -191,7 +191,7 @@ class ASNTest(APIViewTestCases.APIViewTestCase):
 
 class VRFTest(APIViewTestCases.APIViewTestCase):
     model = VRF
-    brief_fields = ['display', 'id', 'name', 'prefix_count', 'rd', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'rd', 'url']
     create_data = [
         {
             'name': 'VRF 4',
@@ -223,7 +223,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase):
 
 class RouteTargetTest(APIViewTestCases.APIViewTestCase):
     model = RouteTarget
-    brief_fields = ['display', 'id', 'name', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'url']
     create_data = [
         {
             'name': '65000:1004',
@@ -252,7 +252,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase):
 
 class RIRTest(APIViewTestCases.APIViewTestCase):
     model = RIR
-    brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url']
+    brief_fields = ['aggregate_count', 'description', 'display', 'id', 'name', 'slug', 'url']
     create_data = [
         {
             'name': 'RIR 4',
@@ -284,7 +284,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase):
 
 class AggregateTest(APIViewTestCases.APIViewTestCase):
     model = Aggregate
-    brief_fields = ['display', 'family', 'id', 'prefix', 'url']
+    brief_fields = ['description', 'display', 'family', 'id', 'prefix', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -323,7 +323,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase):
 
 class RoleTest(APIViewTestCases.APIViewTestCase):
     model = Role
-    brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
+    brief_fields = ['description', 'display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
     create_data = [
         {
             'name': 'Role 4',
@@ -355,7 +355,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase):
 
 class PrefixTest(APIViewTestCases.APIViewTestCase):
     model = Prefix
-    brief_fields = ['_depth', 'display', 'family', 'id', 'prefix', 'url']
+    brief_fields = ['_depth', 'description', 'display', 'family', 'id', 'prefix', 'url']
     create_data = [
         {
             'prefix': '192.168.4.0/24',
@@ -534,7 +534,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
 
 class IPRangeTest(APIViewTestCases.APIViewTestCase):
     model = IPRange
-    brief_fields = ['display', 'end_address', 'family', 'id', 'start_address', 'url']
+    brief_fields = ['description', 'display', 'end_address', 'family', 'id', 'start_address', 'url']
     create_data = [
         {
             'start_address': '192.168.4.10/24',
@@ -633,7 +633,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
 
 class IPAddressTest(APIViewTestCases.APIViewTestCase):
     model = IPAddress
-    brief_fields = ['address', 'display', 'family', 'id', 'url']
+    brief_fields = ['address', 'description', 'display', 'family', 'id', 'url']
     create_data = [
         {
             'address': '192.168.0.4/24',
@@ -718,7 +718,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
 
 class FHRPGroupTest(APIViewTestCases.APIViewTestCase):
     model = FHRPGroup
-    brief_fields = ['display', 'group_id', 'id', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'group_id', 'id', 'protocol', 'url']
     bulk_update_data = {
         'protocol': FHRPGroupProtocolChoices.PROTOCOL_GLBP,
         'group_id': 200,
@@ -839,7 +839,7 @@ class FHRPGroupAssignmentTest(APIViewTestCases.APIViewTestCase):
 
 class VLANGroupTest(APIViewTestCases.APIViewTestCase):
     model = VLANGroup
-    brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count']
+    brief_fields = ['description', 'display', 'id', 'name', 'slug', 'url', 'vlan_count']
     create_data = [
         {
             'name': 'VLAN Group 4',
@@ -960,7 +960,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
 
 class VLANTest(APIViewTestCases.APIViewTestCase):
     model = VLAN
-    brief_fields = ['display', 'id', 'name', 'url', 'vid']
+    brief_fields = ['description', 'display', 'id', 'name', 'url', 'vid']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1020,7 +1020,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
 
 class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ServiceTemplate
-    brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
     bulk_update_data = {
         'description': 'New description',
     }
@@ -1055,7 +1055,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
 
 class ServiceTest(APIViewTestCases.APIViewTestCase):
     model = Service
-    brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
+    brief_fields = ['description', 'display', 'id', 'name', 'ports', 'protocol', 'url']
     bulk_update_data = {
         'description': 'New description',
     }

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

+ 8 - 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.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from netaddr import IPNetwork
@@ -59,11 +60,13 @@ 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'):
@@ -83,7 +86,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):
@@ -96,8 +99,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

+ 17 - 4
netbox/netbox/api/viewsets/__init__.py

@@ -34,6 +34,8 @@ class BaseViewSet(GenericViewSet):
     """
     Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions.
     """
+    brief = False
+
     def initial(self, request, *args, **kwargs):
         super().initial(request, *args, **kwargs)
 
@@ -42,6 +44,13 @@ class BaseViewSet(GenericViewSet):
             if action := HTTP_ACTIONS[request.method]:
                 self.queryset = self.queryset.restrict(request.user, action)
 
+    def initialize_request(self, request, *args, **kwargs):
+
+        # Annotate whether brief mode is active
+        self.brief = request.method == 'GET' and request.GET.get('brief')
+
+        return super().initialize_request(request, *args, **kwargs)
+
     def get_queryset(self):
         qs = super().get_queryset()
         serializer_class = self.get_serializer_class()
@@ -66,12 +75,17 @@ class BaseViewSet(GenericViewSet):
 
     @cached_property
     def requested_fields(self):
-        requested_fields = self.request.query_params.get('fields')
-        return requested_fields.split(',') if requested_fields else []
+        # An explicit list of fields was requested
+        if requested_fields := self.request.query_params.get('fields'):
+            return requested_fields.split(',')
+        # Brief mode has been enabled for this request
+        elif self.brief:
+            serializer_class = self.get_serializer_class()
+            return getattr(serializer_class.Meta, 'brief_fields', None)
+        return None
 
 
 class NetBoxReadOnlyModelViewSet(
-    mixins.BriefModeMixin,
     mixins.CustomFieldsMixin,
     mixins.ExportTemplatesMixin,
     drf_mixins.RetrieveModelMixin,
@@ -85,7 +99,6 @@ class NetBoxModelViewSet(
     mixins.BulkUpdateModelMixin,
     mixins.BulkDestroyModelMixin,
     mixins.ObjectValidationMixin,
-    mixins.BriefModeMixin,
     mixins.CustomFieldsMixin,
     mixins.ExportTemplatesMixin,
     drf_mixins.CreateModelMixin,

+ 0 - 35
netbox/netbox/api/viewsets/mixins.py

@@ -1,5 +1,3 @@
-import logging
-
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.db import transaction
@@ -8,13 +6,9 @@ from rest_framework import status
 from rest_framework.response import Response
 
 from extras.models import ExportTemplate
-from netbox.api.exceptions import SerializerNotFound
 from netbox.api.serializers import BulkOperationSerializer
-from netbox.constants import NESTED_SERIALIZER_PREFIX
-from utilities.api import get_serializer_for_model
 
 __all__ = (
-    'BriefModeMixin',
     'BulkDestroyModelMixin',
     'BulkUpdateModelMixin',
     'CustomFieldsMixin',
@@ -24,35 +18,6 @@ __all__ = (
 )
 
 
-class BriefModeMixin:
-    """
-    Enables brief mode support, so that the client can invoke a model's nested serializer by passing e.g.
-        GET /api/dcim/sites/?brief=True
-    """
-    brief = False
-
-    def initialize_request(self, request, *args, **kwargs):
-        # Annotate whether brief mode is active
-        self.brief = request.method == 'GET' and request.GET.get('brief')
-
-        return super().initialize_request(request, *args, **kwargs)
-
-    def get_serializer_class(self):
-        logger = logging.getLogger(f'netbox.api.views.{self.__class__.__name__}')
-
-        # If using 'brief' mode, find and return the nested serializer for this model, if one exists
-        if self.brief:
-            logger.debug("Request is for 'brief' format; initializing nested serializer")
-            try:
-                return get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
-            except SerializerNotFound:
-                logger.debug(
-                    f"Nested serializer for {self.queryset.model} not found! Using serializer {self.serializer_class}"
-                )
-
-        return self.serializer_class
-
-
 class CustomFieldsMixin:
     """
     For models which support custom fields, populate the `custom_fields` context.

+ 7 - 3
netbox/netbox/authentication.py

@@ -4,12 +4,13 @@ from collections import defaultdict
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
-from django.contrib.auth.models import Group, AnonymousUser
+from django.contrib.auth.models import 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
+from users.models import Group, ObjectPermission
 from utilities.permissions import (
     permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct,
 )
@@ -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):

+ 61 - 43
netbox/netbox/models/features.py

@@ -5,8 +5,6 @@ from functools import cached_property
 from django.contrib.contenttypes.fields import GenericRelation
 from django.core.validators import ValidationError
 from django.db import models
-from django.db.models.signals import class_prepared
-from django.dispatch import receiver
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from taggit.managers import TaggableManager
@@ -14,7 +12,7 @@ from taggit.managers import TaggableManager
 from core.choices import JobStatusChoices
 from core.models import ContentType
 from extras.choices import *
-from extras.utils import is_taggable, register_features
+from extras.utils import is_taggable
 from netbox.config import get_config
 from netbox.registry import registry
 from netbox.signals import post_clean
@@ -37,6 +35,7 @@ __all__ = (
     'JournalingMixin',
     'SyncedDataMixin',
     'TagsMixin',
+    'register_models',
 )
 
 
@@ -275,16 +274,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 +492,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 +550,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__
+        ))
 
 
 #
@@ -576,36 +581,49 @@ registry['model_features'].update({
 })
 
 
-@receiver(class_prepared)
-def _register_features(sender, **kwargs):
-    # Record each applicable feature for the model in the registry
-    features = {
-        feature for feature, cls in FEATURES_MAP.items() if issubclass(sender, cls)
-    }
-    register_features(sender, features)
-
-    # Register applicable feature views for the model
-    if issubclass(sender, JournalingMixin):
-        register_model_view(
-            sender,
-            'journal',
-            kwargs={'model': sender}
-        )('netbox.views.generic.ObjectJournalView')
-    if issubclass(sender, ChangeLoggingMixin):
-        register_model_view(
-            sender,
-            'changelog',
-            kwargs={'model': sender}
-        )('netbox.views.generic.ObjectChangeLogView')
-    if issubclass(sender, JobsMixin):
-        register_model_view(
-            sender,
-            'jobs',
-            kwargs={'model': sender}
-        )('netbox.views.generic.ObjectJobsView')
-    if issubclass(sender, SyncedDataMixin):
-        register_model_view(
-            sender,
-            'sync',
-            kwargs={'model': sender}
-        )('netbox.views.generic.ObjectSyncDataView')
+def register_models(*models):
+    """
+    Register one or more models in NetBox. This entails:
+
+     - Determining whether the model is considered "public" (available for reference by other models)
+     - Registering which features the model supports (e.g. bookmarks, custom fields, etc.)
+     - Registering any feature-specific views for the model (e.g. ObjectJournalView instances)
+
+    register_model() should be called for each relevant model under the ready() of an app's AppConfig class.
+    """
+    for model in models:
+        app_label, model_name = model._meta.label_lower.split('.')
+
+        # Register public models
+        if not getattr(model, '_netbox_private', False):
+            registry['models'][app_label].add(model_name)
+
+        # Record each applicable feature for the model in the registry
+        features = {
+            feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls)
+        }
+        for feature in features:
+            try:
+                registry['model_features'][feature][app_label].add(model_name)
+            except KeyError:
+                raise KeyError(
+                    f"{feature} is not a valid model feature! Valid keys are: {registry['model_features'].keys()}"
+                )
+
+        # Register applicable feature views for the model
+        if issubclass(model, JournalingMixin):
+            register_model_view(model, 'journal', kwargs={'model': model})(
+                'netbox.views.generic.ObjectJournalView'
+            )
+        if issubclass(model, ChangeLoggingMixin):
+            register_model_view(model, 'changelog', kwargs={'model': model})(
+                'netbox.views.generic.ObjectChangeLogView'
+            )
+        if issubclass(model, JobsMixin):
+            register_model_view(model, 'jobs', kwargs={'model': model})(
+                'netbox.views.generic.ObjectJobsView'
+            )
+        if issubclass(model, SyncedDataMixin):
+            register_model_view(model, 'sync', kwargs={'model': model})(
+                'netbox.views.generic.ObjectSyncDataView'
+            )

+ 3 - 3
netbox/netbox/navigation/menu.py

@@ -392,19 +392,19 @@ ADMIN_MENU = Menu(
                 ),
                 # Proxy model for auth.Group
                 MenuItem(
-                    link=f'users:netboxgroup_list',
+                    link=f'users:group_list',
                     link_text=_('Groups'),
                     permissions=[f'auth.view_group'],
                     staff_only=True,
                     buttons=(
                         MenuItemButton(
-                            link=f'users:netboxgroup_add',
+                            link=f'users:group_add',
                             title='Add',
                             icon_class='mdi mdi-plus-thick',
                             permissions=[f'auth.add_group']
                         ),
                         MenuItemButton(
-                            link=f'users:netboxgroup_import',
+                            link=f'users:group_import',
                             title='Import',
                             icon_class='mdi mdi-upload',
                             permissions=[f'auth.add_group']

+ 5 - 0
netbox/netbox/plugins/__init__.py

@@ -94,6 +94,11 @@ class PluginConfig(AppConfig):
             pass
 
     def ready(self):
+        from netbox.models.features import register_models
+
+        # Register models
+        register_models(*self.get_models())
+
         plugin_name = self.name.rsplit('.', 1)[-1]
 
         # Register search extensions (if defined)

+ 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

+ 2 - 2
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ from netbox.plugins import PluginConfig
 # Environment setup
 #
 
-VERSION = '3.7.3-dev'
+VERSION = '4.0.0-dev'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -156,6 +156,7 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', [])
 REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', [])
 REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', [])
 REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|')
+# Required by extras/migrations/0109_script_models.py
 REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
 RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
 RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60)
@@ -579,7 +580,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',

+ 1 - 2
netbox/netbox/tests/test_authentication.py

@@ -2,7 +2,6 @@ import datetime
 
 from django.conf import settings
 from django.contrib.auth import get_user_model
-from django.contrib.auth.models import Group
 from django.contrib.contenttypes.models import ContentType
 from django.test import Client
 from django.test.utils import override_settings
@@ -12,7 +11,7 @@ from rest_framework.test import APIClient
 
 from dcim.models import Site
 from ipam.models import Prefix
-from users.models import ObjectPermission, Token
+from users.models import Group, ObjectPermission, Token
 from utilities.testing import TestCase
 from utilities.testing.api import APITestCase
 

+ 4 - 0
netbox/netbox/tests/test_plugins.py

@@ -20,6 +20,10 @@ class PluginTest(TestCase):
 
         self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS)
 
+    def test_model_registration(self):
+        self.assertIn('dummy_plugin', registry['models'])
+        self.assertIn('dummymodel', registry['models']['dummy_plugin'])
+
     def test_models(self):
         from netbox.tests.dummy_plugin.models import DummyModel
 

+ 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
@@ -319,7 +320,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)
@@ -344,11 +345,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
@@ -389,7 +393,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
@@ -415,7 +419,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
@@ -100,7 +101,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):
         """

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
netbox/project-static/dist/netbox.js.map


BIN
netbox/project-static/img/tint_20.png


+ 6 - 5
netbox/project-static/src/objectSelector.ts

@@ -18,11 +18,12 @@ function handleSelection(link: HTMLAnchorElement): void {
   const value = link.getAttribute('data-value');
 
   //@ts-ignore
-  target.slim.setData([
-    {text: label, value: value}
-  ]);
-  const change = new Event('change');
-  target.dispatchEvent(change);
+  target.tomselect.addOption({
+    id: value,
+    display: label,
+  });
+  //@ts-ignore
+  target.tomselect.addItem(value);
 
 }
 

+ 1 - 1
netbox/project-static/src/select/dynamic.ts

@@ -42,7 +42,7 @@ function renderItem(data: TomOption, escape: typeof escape_html) {
 
 // Initialize <select> elements which are populated via a REST API call
 export function initDynamicSelects(): void {
-  for (const select of getElements<HTMLSelectElement>('select.api-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.api-select:not(.tomselected)')) {
     new DynamicTomSelect(select, {
       ...config,
       valueField: VALUE_FIELD,

+ 4 - 2
netbox/project-static/src/select/static.ts

@@ -7,10 +7,11 @@ import { getElements } from '../util';
 // Initialize <select> elements with statically-defined options
 export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
-    'select:not(.api-select):not(.color-select)',
+    'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
   )) {
     new TomSelect(select, {
       ...config,
+      maxOptions: undefined,
     });
   }
 }
@@ -23,9 +24,10 @@ export function initColorSelects(): void {
     )}"></span> ${escape(item.text)}</div>`;
   }
 
-  for (const select of getElements<HTMLSelectElement>('select.color-select')) {
+  for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
     new TomSelect(select, {
       ...config,
+      maxOptions: undefined,
       render: {
         option: renderColor,
         item: renderColor,

+ 0 - 23
netbox/project-static/src/util.ts

@@ -244,29 +244,6 @@ export function getSelectedOptions<E extends HTMLElement>(
   return selected;
 }
 
-/**
- * Get data that can only be accessed via Django context, and is thus already rendered in the HTML
- * template.
- *
- * @see Templates requiring Django context data have a `{% block data %}` block.
- *
- * @param key Property name, which must exist on the HTML element. If not already prefixed with
- *            `data-`, `data-` will be prepended to the property.
- * @returns Value if it exists, `null` if not.
- */
-export function getNetboxData(key: string): string | null {
-  if (!key.startsWith('data-')) {
-    key = `data-${key}`;
-  }
-  for (const element of getElements('body > div#netbox-data > *')) {
-    const value = element.getAttribute(key);
-    if (isTruthy(value)) {
-      return value;
-    }
-  }
-  return null;
-}
-
 /**
  * Toggle visibility of an element.
  */

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff